From 11773170f09d0e2261a326cc611b058687b05498 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:36:27 +0800 Subject: [PATCH 001/132] docs: propose unstable LLM API recovery RFC --- docs/rfc/README.md | 1 + .../2026-06-21-unstable-llm-api-recovery.md | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 3778aaf4ad..64b5743433 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -65,6 +65,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | | [Extract example apps into packages](proposed/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | | [Branded IDs everywhere they belong](proposed/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | +| [Treat unstable LLM APIs as a first-class failure mode](proposed/architecture/2026-06-21-unstable-llm-api-recovery.md) | 2026-06-21 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md new file mode 100644 index 0000000000..8a498e2e19 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md @@ -0,0 +1,154 @@ +# RFC: Treat unstable LLM APIs as a first-class failure mode + +Status: proposed + +## Problem + +LLM APIs are not a stable local function call. They rate-limit, overload, return 5xx/502/503 from gateways, close streaming sockets before `[DONE]`, emit malformed or provider-specific error payloads, hang mid-stream, surface SDK errors as in-band events, and sometimes require a delayed retry using `Retry-After`. The harness currently contains good error containment, but it does not yet treat this API instability as a first-class design problem. + +`dsh-llm` defines two sanctioned adapter failure paths - throw from `stream()` or end with `finish { kind: 'error' | 'aborted' }` - and the agent loop translates both into a failed step instead of logging a fake completed assistant message. That was the right MVP containment baseline, documented in [the architecture](../../../architecture.md) and reinforced by [the twin-adapter RFC](../../implemented/architecture/2026-06-13-twin-llm-adapters.md). It is not enough for an agent that depends on an unstable remote model API for every turn. + +The audit found four load-bearing gaps. + +- `LlmError` and `FinishReasonMap.error` carry only `message`, `code`, and sometimes HTTP `status` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts), [packages/llm/llm/src/types.ts](../../../../packages/llm/llm/src/types.ts)). A caller cannot reliably tell "retry this after 800 ms on the same endpoint", "fail over to another route for the same model", "ask the user for credentials", "never retry because the request is invalid", "provider truncated the stream after committed output", or "adapter protocol bug" without provider-specific heuristics. +- The `llm/stream` waterfall is documented as the place for retry/routing/caching, but its value is one committed `AsyncIterable`. A listener can technically catch an API error and call `next()` again, but once it has yielded any chunks to the agent loop those chunks are already appended as `assistant/chunk` events and emitted to UI. Retrying after that point would concatenate chunks from two provider attempts into one model step, corrupting replay and the user transcript. +- The adapter registry is one adapter per model name. That makes "logical model" and "concrete API route" the same thing, so the service has no vocabulary for "same model through another endpoint or SDK", "same provider region with different health", "fallback route with compatible capabilities", provider request ids, or per-route backoff state. +- Crash recovery and LLM API recovery are easy to conflate. `dsh-session` repairs an interrupted durable log by closing an open turn and synthesizing missing tool results ([packages/core/session/src/repair.ts](../../../../packages/core/session/src/repair.ts)); that preserves already-written work after a process crash. It does not make a failed provider attempt safe to retry, discard, replay, or fail over. + +The result is a system that can survive an unstable LLM API without killing the loop, but cannot make principled recovery decisions. For a coding agent, that is underdesigned: ordinary provider turbulence should not require every UI or product plugin to reinvent retries around an unsafe stream boundary. + +## Proposal + +Introduce an LLM-call v2 contract centered on API-instability recovery: classify provider/API failures, separate provider attempts from committed model output, route logical models through recoverable API routes, and make conservative retry/failover the default behavior in `dsh-llm`. Because the harness is unreleased, this should be a breaking cleanup rather than a compatibility layer around the underspecified v1 surface. + +### 1. Replace flat error codes with a serializable `LlmFailure` + +Keep `HarnessError` as the common thrown-error base, but make LLM failures carry a structured, JSON-serializable payload. `code` remains a stable leaf label for logs and provider-specific matching; retry/failover policy branches on the structured fields. + +```ts ignore-check +type LlmFailureClass = + | 'auth' + | 'rate-limit' + | 'quota' + | 'invalid-request' + | 'unsupported' + | 'timeout' + | 'transport' + | 'provider-overloaded' + | 'provider-unavailable' + | 'provider-bug' + | 'protocol' + | 'safety' + | 'aborted' + | 'unknown' + +type LlmFailurePhase = + | 'request-build' + | 'connect' + | 'response-headers' + | 'stream' + | 'finish' + +interface LlmFailure { + message: string + code: string + class: LlmFailureClass + phase: LlmFailurePhase + retryable: boolean + failover: 'never' | 'same-model' | 'compatible-model' + partialOutput: 'none' | 'uncommitted' | 'committed' + provider?: string + routeId?: string + model?: string + wireModel?: string + status?: number + retryAfterMs?: number + requestId?: string +} +``` + +`LlmError` should carry `failure: LlmFailure`; `FinishReasonMap.error` should carry the same payload instead of a parallel `{ message, code? }` shape. The agent loop should persist the serializable failure fields in `session error` and `turn/end { kind: 'error' }`, while the thrown `LlmError` may still carry a non-serializable `cause` chain for local debugging. + +Adapters are responsible for faithful provider/API classification at their boundary: HTTP status, `Retry-After`, provider request id headers, SDK error type, timeout vs caller abort, malformed SSE, missing `[DONE]`, unknown finish reason, and unsupported local request shape. The current pi-ai adapter's regex over message text is acceptable only as a temporary fallback when the SDK hides the real status; the adapter should prefer structured SDK/provider fields when available. + +### 2. Split provider attempts from committed model output + +Make "attempt" a first-class boundary below `ctx.llm.stream()`. An adapter streams one provider API attempt. The LLM service runs zero or more attempts according to recovery policy and yields only committed output to the agent loop. + +The important invariant: chunks from a failed attempt must never be silently spliced together with chunks from a later attempt as one assistant step. Recovery must choose one of these paths instead: + +- **Retry before commit.** If an API attempt fails before any chunks are committed to the loop, the service may retry or fail over and hide the failed attempt from `assistant/chunk` history, while recording attempt diagnostics separately. +- **Commit and stop retrying.** Once chunks are committed to the loop, the attempt owns the visible step. If it later fails, the step fails with `partialOutput: 'committed'`; automatic retry is not allowed unless a later RFC designs an explicit continuation/repair protocol. +- **Buffered recovery mode.** A caller or policy may choose to buffer an entire attempt until it reaches `finish`, then yield the winning attempt's chunks. This improves retryability against flaky APIs at the cost of live token streaming and should be a deliberate mode, not an accidental side effect. + +This likely means replacing the single overloaded `llm/stream` waterfall with narrower hooks: one around a single provider attempt, one around recovery policy decisions, and one around the committed stream. Names are implementation details for the follow-up PR, but the semantics are not: plugins must be able to wrap "one API attempt" without pretending they can safely retry already-committed chunks. + +### 3. Route logical models through recoverable API routes + +Separate the logical model a caller requests from the concrete provider route that serves an attempt. Replace "one adapter per model name" with route registration, for example: + +```ts ignore-check +ctx.llm.registerRoute({ + routeId: 'deepseek-direct:deepseek-v4-flash', + model: 'deepseek-v4-flash', + wireModel: 'deepseek-v4-flash', + provider: 'deepseek', + adapter, + priority: 0, + capabilities: { tools: true, reasoning: true, images: false, prefill: false }, +}) +``` + +`GenerateOptions.model` remains the logical model. The service resolves it to a route for each API attempt, records the route in failure/attempt diagnostics, and can retry on the same route or fail over to another route with compatible capabilities. Duplicate model names become normal; duplicate route ids are the conflict. This is the smallest vocabulary that can express direct endpoint vs SDK-backed endpoint, regional endpoints, and future fallback models without making every caller own routing. + +### 4. Put default API recovery policy in `dsh-llm` + +Adapters should not perform hidden SDK retries unless those retries are surfaced as attempts with classified failures. The service owns the default policy so every consumer gets the same behavior and the same audit trail. + +Default policy should be conservative: + +- Retry transient API failures (`rate-limit`, `timeout`, `transport`, `provider-overloaded`, `provider-unavailable`) only before committed output. +- Honor `retryAfterMs`, otherwise use bounded exponential backoff with jitter. +- Treat 429/408/409/425/500/502/503/504 and connection resets as potentially recoverable unless the provider payload says otherwise; treat 400/401/403, unsupported local options, caller abort, and adapter protocol bugs as non-retryable. +- Fail over only when the failure says failover is safe and the candidate route advertises compatible capabilities for the request (`tools`, reasoning passback, images, prefill, stop sequences, strict tools). +- Share the caller's `AbortSignal` across the whole recovered call, and expose per-attempt timeouts as explicit policy. A stuck stream must time out in a controlled way instead of hanging the turn forever. +- Bound attempts by count and elapsed time, with clear failure reporting when the budget is exhausted. + +The policy should be configurable through a typed service option and an event/waterfall seam so product plugins can tighten or loosen it, but the default must be safe enough that a basic agent does not need a custom retry plugin to survive ordinary 429/5xx/connectivity noise. + +### 5. Record API attempt diagnostics without polluting derived history + +The session log should be able to explain what happened during a recovered model call without feeding failed attempts back to the model as assistant output. Add turn-enclosed, derive-skipped diagnostics for LLM API attempts, or an equivalent trace surface if we decide session events should stay conversation-only. The data must be JSON-serializable and include attempt number, route id, failure payload, backoff, provider request id, and whether any chunks were committed. + +`assistant/chunk` remains the replay source for the committed attempt only. Hidden failed attempts are diagnostics, not model history. A stream that fails after committed chunks remains replayable as a thrown stream through the existing `llm-replay` sidecar mechanism, but the failure payload should become structured rather than `{ message, code, status? }`. + +## Out of scope + +This RFC does not propose silent mid-stream continuation after user-visible output. That requires a separate model-history design: either provider-supported prefill/continuation, a recovery prompt that explicitly shows the partial assistant output, or a UI affordance that marks the partial answer as failed and asks the model to continue in a new step. Splicing two API attempts into one assistant message is rejected. + +This RFC also does not solve semantic model-output repair: malformed tool-call JSON, refusal handling, or content-filter fallbacks. Those may use the same failure vocabulary later, but they are higher-level agent behaviors, not unstable-API recovery. + +## Acceptance criteria + +- `LlmError` and in-band finish errors carry one structured `LlmFailure` payload; the agent loop persists that payload's serializable fields in error turn data. +- Recovery policy can distinguish retry, failover, credential/user-action, unsupported request, caller abort, adapter/protocol bug, and post-commit partial stream failure without parsing message text. +- The LLM service has an explicit API-attempt boundary; no retry path can append chunks from two provider attempts as one committed assistant step. +- The route registry allows multiple concrete API routes for one logical model and records the selected route on attempts/failures. +- Default recovery retries transient pre-commit failures with bounded backoff, honors provider retry-after hints, times out stuck streams, disables hidden SDK retries or surfaces them as attempts, and never retries after committed chunks without an explicit continuation design. +- Unit tests cover thrown errors and finish-error chunks through the real agent loop, retry-before-first-chunk, failover to a second compatible route, retry budget exhaustion, abort during backoff, stream timeout, and the "partial chunks then failure does not retry/splice" invariant. +- Adapter tests classify representative HTTP statuses, retry-after headers, request ids, malformed/truncated SSE streams, SDK in-stream errors, caller aborts, and unsupported options into `LlmFailure`. +- Snapshot/replay support can faithfully represent a recovered call and a post-commit thrown stream without losing the structured failure payload. +- Docs updated in the same change: [the architecture LLM section](../../../architecture.md), [the LLM adapter cookbook](../../../cookbook/adding-an-llm-adapter.md), and the LLM package READMEs. + +## Risks / what we give up + +- **More surface area in the LLM core.** API recovery adds policy, route state, attempt diagnostics, and tests. That complexity belongs in `dsh-llm` because every consumer otherwise reinvents it around the same unsafe stream boundary. +- **Some modes reduce live streaming.** Buffered recovery trades first-token latency for safe retry. That should be opt-in or policy-driven; the default can still stream eagerly while retrying only before commit. +- **Breaking adapter churn.** Existing adapters will change from "stream chunks or throw a flat `LlmError`" to "stream one classified API attempt." Pre-release rules favor the correct seam over shims. +- **Route compatibility is easy to overclaim.** A route must advertise concrete capabilities, and failover must check the request actually fits them. "Same model name" is not enough when one route lacks strict tools, reasoning passback, images, stop sequences, or prefill. + +## Related + +- Builds on [Provider-neutral content-block vocabulary](../../implemented/architecture/2026-06-11-content-block-vocabulary.md): the content vocabulary stays provider-neutral; this adds a provider-neutral failure/recovery vocabulary beside it. +- Revises the scope implied by [Two LLM adapters as a design-verification twin](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the twin validated chunk shape and error delivery paths, but it also exposed that delivery paths are not enough for unstable API recovery. +- Extends [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md): `HarnessError.code` was the foundation; LLM API recovery needs a richer payload because retry/failover policy cannot safely branch on one flat string. From e141ffa27f9c10e8a5157b81805fdd4236991904 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:02:35 +0800 Subject: [PATCH 002/132] docs: tighten LLM recovery RFC scope --- .../2026-06-21-unstable-llm-api-recovery.md | 123 ++++++++++++------ 1 file changed, 86 insertions(+), 37 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md index 8a498e2e19..f5f7659c41 100644 --- a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md +++ b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md @@ -6,26 +6,25 @@ Status: proposed LLM APIs are not a stable local function call. They rate-limit, overload, return 5xx/502/503 from gateways, close streaming sockets before `[DONE]`, emit malformed or provider-specific error payloads, hang mid-stream, surface SDK errors as in-band events, and sometimes require a delayed retry using `Retry-After`. The harness currently contains good error containment, but it does not yet treat this API instability as a first-class design problem. -`dsh-llm` defines two sanctioned adapter failure paths - throw from `stream()` or end with `finish { kind: 'error' | 'aborted' }` - and the agent loop translates both into a failed step instead of logging a fake completed assistant message. That was the right MVP containment baseline, documented in [the architecture](../../../architecture.md) and reinforced by [the twin-adapter RFC](../../implemented/architecture/2026-06-13-twin-llm-adapters.md). It is not enough for an agent that depends on an unstable remote model API for every turn. +`dsh-llm` defines two sanctioned adapter failure paths - throw from `stream()` or end with `finish { kind: 'error' | 'aborted' }` - and downstream consumers are expected to treat both as failed model calls. That was the right MVP containment baseline, documented in [the architecture](../../../architecture.md) and reinforced by [the twin-adapter RFC](../../implemented/architecture/2026-06-13-twin-llm-adapters.md). It is not enough for callers that depend on an unstable remote model API for every turn, and it forces every caller to understand two failure delivery mechanisms. -The audit found four load-bearing gaps. +The audit found three load-bearing gaps. - `LlmError` and `FinishReasonMap.error` carry only `message`, `code`, and sometimes HTTP `status` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts), [packages/llm/llm/src/types.ts](../../../../packages/llm/llm/src/types.ts)). A caller cannot reliably tell "retry this after 800 ms on the same endpoint", "fail over to another route for the same model", "ask the user for credentials", "never retry because the request is invalid", "provider truncated the stream after committed output", or "adapter protocol bug" without provider-specific heuristics. -- The `llm/stream` waterfall is documented as the place for retry/routing/caching, but its value is one committed `AsyncIterable`. A listener can technically catch an API error and call `next()` again, but once it has yielded any chunks to the agent loop those chunks are already appended as `assistant/chunk` events and emitted to UI. Retrying after that point would concatenate chunks from two provider attempts into one model step, corrupting replay and the user transcript. +- The `llm/stream` waterfall is documented as the place for retry/routing/caching, but its value is one raw `AsyncIterable`. A listener can technically catch an API error and call `next()` again, but once it has yielded chunks, callers may already have rendered them, buffered them as output, or executed side effects based on completed tool calls. Retrying after that point can concatenate chunks from two provider responses into one apparent model output. The current surface has no canonical way to say "the tokens you saw were tentative; this response timed out, so discard them and restart." - The adapter registry is one adapter per model name. That makes "logical model" and "concrete API route" the same thing, so the service has no vocabulary for "same model through another endpoint or SDK", "same provider region with different health", "fallback route with compatible capabilities", provider request ids, or per-route backoff state. -- Crash recovery and LLM API recovery are easy to conflate. `dsh-session` repairs an interrupted durable log by closing an open turn and synthesizing missing tool results ([packages/core/session/src/repair.ts](../../../../packages/core/session/src/repair.ts)); that preserves already-written work after a process crash. It does not make a failed provider attempt safe to retry, discard, replay, or fail over. -The result is a system that can survive an unstable LLM API without killing the loop, but cannot make principled recovery decisions. For a coding agent, that is underdesigned: ordinary provider turbulence should not require every UI or product plugin to reinvent retries around an unsafe stream boundary. +The result is a package that can surface an unstable LLM API failure, but cannot make principled recovery decisions for its callers. Ordinary provider turbulence should not require every consumer to reinvent retries around an unsafe stream boundary. ## Proposal -Introduce an LLM-call v2 contract centered on API-instability recovery: classify provider/API failures, separate provider attempts from committed model output, route logical models through recoverable API routes, and make conservative retry/failover the default behavior in `dsh-llm`. Because the harness is unreleased, this should be a breaking cleanup rather than a compatibility layer around the underspecified v1 surface. +Introduce an LLM-call v2 contract centered on API-instability recovery: classify provider/API failures, separate provider responses from committed model output, route logical models through recoverable API routes, and make conservative retry/failover the default behavior in `dsh-llm`. Because the harness is unreleased, this should be a breaking cleanup rather than a compatibility layer around the underspecified v1 surface. ### 1. Replace flat error codes with a serializable `LlmFailure` Keep `HarnessError` as the common thrown-error base, but make LLM failures carry a structured, JSON-serializable payload. `code` remains a stable leaf label for logs and provider-specific matching; retry/failover policy branches on the structured fields. -```ts ignore-check +```ts type LlmFailureClass = | 'auth' | 'rate-limit' @@ -67,25 +66,46 @@ interface LlmFailure { } ``` -`LlmError` should carry `failure: LlmFailure`; `FinishReasonMap.error` should carry the same payload instead of a parallel `{ message, code? }` shape. The agent loop should persist the serializable failure fields in `session error` and `turn/end { kind: 'error' }`, while the thrown `LlmError` may still carry a non-serializable `cause` chain for local debugging. +`LlmError` should carry `failure: LlmFailure`; `FinishReasonMap.error` should carry the same payload instead of a parallel `{ message, code? }` shape. Adapter-thrown errors and in-band finish errors are input forms to the recovery layer. The public lifecycle stream should convert classified LLM API failures into terminal lifecycle events rather than requiring callers to catch thrown provider errors. The failure payload is serializable so callers can log or persist it if they choose, while the internal thrown `LlmError` may still carry a non-serializable `cause` chain for local debugging. Adapters are responsible for faithful provider/API classification at their boundary: HTTP status, `Retry-After`, provider request id headers, SDK error type, timeout vs caller abort, malformed SSE, missing `[DONE]`, unknown finish reason, and unsupported local request shape. The current pi-ai adapter's regex over message text is acceptable only as a temporary fallback when the SDK hides the real status; the adapter should prefer structured SDK/provider fields when available. -### 2. Split provider attempts from committed model output +### 2. Split provider responses from committed model output -Make "attempt" a first-class boundary below `ctx.llm.stream()`. An adapter streams one provider API attempt. The LLM service runs zero or more attempts according to recovery policy and yields only committed output to the agent loop. +Make "response" a first-class boundary in `ctx.llm.stream()`. An adapter streams one provider API response. The LLM service runs zero or more responses according to recovery policy and exposes one canonical response-lifecycle stream to consumers. Convenience APIs may expose a committed-or-failed result for simple callers, but that result must be derived from the lifecycle stream, not a parallel contract. -The important invariant: chunks from a failed attempt must never be silently spliced together with chunks from a later attempt as one assistant step. Recovery must choose one of these paths instead: +The primary response id is generated by the harness before the adapter call starts. Provider response ids and request ids are metadata attached when known; they are not the primary key because providers may omit them, report them only after the stream starts, reuse them in surprising ways, or fail before one exists. -- **Retry before commit.** If an API attempt fails before any chunks are committed to the loop, the service may retry or fail over and hide the failed attempt from `assistant/chunk` history, while recording attempt diagnostics separately. -- **Commit and stop retrying.** Once chunks are committed to the loop, the attempt owns the visible step. If it later fails, the step fails with `partialOutput: 'committed'`; automatic retry is not allowed unless a later RFC designs an explicit continuation/repair protocol. -- **Buffered recovery mode.** A caller or policy may choose to buffer an entire attempt until it reaches `finish`, then yield the winning attempt's chunks. This improves retryability against flaky APIs at the cost of live token streaming and should be a deliberate mode, not an accidental side effect. +The important invariant: chunks from a failed response must never be silently spliced together with chunks from a later response as one apparent model result. Token deltas from a response are tentative until that response reaches a committing terminal finish (`stop`, `tool-calls`, or `max-tokens`). The lifecycle stream must be able to report that tentative tokens were shown live, then discarded because the response timed out, disconnected, or otherwise failed before commit. -This likely means replacing the single overloaded `llm/stream` waterfall with narrower hooks: one around a single provider attempt, one around recovery policy decisions, and one around the committed stream. Names are implementation details for the follow-up PR, but the semantics are not: plugins must be able to wrap "one API attempt" without pretending they can safely retry already-committed chunks. +The event vocabulary should keep the familiar `assistant/chunk` concept but stop pretending every chunk is already final output. A possible spelling is: + +```ts ignore-check +type LlmStreamEvent = + | { type: 'response/start'; responseId: ResponseId; responseIndex: number; routeId: string } + | { type: 'assistant/chunk'; responseId: ResponseId; commitment: 'uncommitted'; chunk: StreamChunk } + | { type: 'response/interrupted'; responseId: ResponseId; failure: LlmFailure; scheduledRetryMs?: number } + | { type: 'response/failed'; responseId?: ResponseId; failure: LlmFailure } + | { type: 'response/committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } + +type GenerateOutcome = + | { type: 'committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } + | { type: 'failed'; responseId?: ResponseId; failure: LlmFailure } +``` + +The implementation may choose the exact names, but the type shape should make the state transition obvious: assistant chunks start uncommitted, then the enclosing response becomes interrupted/discarded, failed, or committed. + +- **Lifecycle assistant chunks.** The lifecycle stream should make the old ambiguity explicit: these events are assistant chunk messages, but each one belongs to a response and has a commitment state. Most arrive as uncommitted live UI state; a response that fails before commit marks them interrupted/discarded, and a response that reaches a committing finish lets the UI mark that response committed. +- **Retry before commit.** If an API response fails before a committing finish, the service may retry or fail over and exclude the failed response from the committed result, while surfacing response diagnostics separately. +- **Commit on terminal finish.** Once a response reaches a committing finish, the response owns the visible result. `dsh-llm` emits a `response/committed` event carrying the fully assembled assistant `Message`, final `FinishReason`, usage, and response metadata. Callers that persist messages, execute tool calls, or otherwise take side effects should use this committed event rather than rebuilding output from lifecycle chunks. +- **Terminal failure.** If recovery reaches a non-retryable failure, is aborted by the caller, or otherwise stops without a committing finish, `dsh-llm` emits `response/failed` carrying the final `LlmFailure` and ends the lifecycle stream normally. Throwing is reserved for defects outside the classified LLM API failure contract. +- **Fail after commit.** If a later failure is ever observable after commit, the lifecycle reports `response/failed` with `partialOutput: 'committed'`; automatic retry is not allowed unless a later RFC designs an explicit continuation/repair protocol. + +This means replacing the single overloaded raw-chunk `llm/stream` waterfall with a lifecycle stream and narrower hooks: one around a single provider response, one around recovery policy decisions, and one around convenience APIs that only expose the terminal outcome. Names are implementation details for the follow-up PR, but the semantics are not: plugins must be able to wrap "one API response" without pretending they can safely retry already-committed chunks. ### 3. Route logical models through recoverable API routes -Separate the logical model a caller requests from the concrete provider route that serves an attempt. Replace "one adapter per model name" with route registration, for example: +Separate the logical model a caller requests from the concrete provider route that serves a response. Replace "one adapter per model name" with route registration, for example: ```ts ignore-check ctx.llm.registerRoute({ @@ -99,52 +119,81 @@ ctx.llm.registerRoute({ }) ``` -`GenerateOptions.model` remains the logical model. The service resolves it to a route for each API attempt, records the route in failure/attempt diagnostics, and can retry on the same route or fail over to another route with compatible capabilities. Duplicate model names become normal; duplicate route ids are the conflict. This is the smallest vocabulary that can express direct endpoint vs SDK-backed endpoint, regional endpoints, and future fallback models without making every caller own routing. +`GenerateOptions.model` remains the logical model. The service resolves it to a route for each API response, records the route in failure/response diagnostics, and can retry on the same route or fail over to another route with compatible capabilities. Duplicate model names become normal; duplicate route ids are the conflict. This is the smallest vocabulary that can express direct endpoint vs SDK-backed endpoint, regional endpoints, and future fallback models without making every caller own routing. ### 4. Put default API recovery policy in `dsh-llm` -Adapters should not perform hidden SDK retries unless those retries are surfaced as attempts with classified failures. The service owns the default policy so every consumer gets the same behavior and the same audit trail. +Adapters should not perform hidden SDK retries unless those retries are surfaced as response lifecycle events with classified failures. The service owns the default policy so every consumer gets the same behavior and the same audit trail. Default policy should be conservative: -- Retry transient API failures (`rate-limit`, `timeout`, `transport`, `provider-overloaded`, `provider-unavailable`) only before committed output. -- Honor `retryAfterMs`, otherwise use bounded exponential backoff with jitter. +- Retry transient API failures (`rate-limit`, `timeout`, `transport`, `provider-overloaded`, `provider-unavailable`) only before committed output, and keep retrying until the caller aborts or the failure class changes to a non-retryable one. +- Honor `retryAfterMs` up to `maxRetryDelayMs`, otherwise use bounded exponential backoff with jitter. The same cap applies to both provider-supplied retry hints and ordinary exponential backoff; diagnostics record whether the delay source was `provider-retry-after` or `exponential-backoff`. - Treat 429/408/409/425/500/502/503/504 and connection resets as potentially recoverable unless the provider payload says otherwise; treat 400/401/403, unsupported local options, caller abort, and adapter protocol bugs as non-retryable. - Fail over only when the failure says failover is safe and the candidate route advertises compatible capabilities for the request (`tools`, reasoning passback, images, prefill, stop sequences, strict tools). -- Share the caller's `AbortSignal` across the whole recovered call, and expose per-attempt timeouts as explicit policy. A stuck stream must time out in a controlled way instead of hanging the turn forever. -- Bound attempts by count and elapsed time, with clear failure reporting when the budget is exhausted. +- Share the caller's `AbortSignal` across the whole recovered call, and expose per-response timeouts as explicit policy. A stuck stream must time out in a controlled way instead of hanging the turn forever. +- Surface every retry decision to the UI with retry count, backoff delay, route, and failure summary, so an actively watching user can tell the agent is waiting on provider capacity instead of frozen. -The policy should be configurable through a typed service option and an event/waterfall seam so product plugins can tighten or loosen it, but the default must be safe enough that a basic agent does not need a custom retry plugin to survive ordinary 429/5xx/connectivity noise. +The policy should be configurable through a typed service option and an event/waterfall seam so product plugins can adjust timing/backoff details, but the default retry posture is not opt-in: a basic agent should keep recovering from retryable 429/5xx/connectivity noise until cancelled. -### 5. Record API attempt diagnostics without polluting derived history +The zero-config defaults should be sensible production behavior, not placeholders: -The session log should be able to explain what happened during a recovered model call without feeding failed attempts back to the model as assistant output. Add turn-enclosed, derive-skipped diagnostics for LLM API attempts, or an equivalent trace surface if we decide session events should stay conversation-only. The data must be JSON-serializable and include attempt number, route id, failure payload, backoff, provider request id, and whether any chunks were committed. +```ts +const defaultLlmRecoveryConfig = { + maxResponses: 'unbounded', + maxElapsedMs: 'unbounded', + connectTimeoutMs: 15_000, + responseHeaderTimeoutMs: 60_000, + streamIdleTimeoutMs: 5 * 60_000, + initialBackoffMs: 200, + maxRetryDelayMs: 10 * 60_000, + jitterRatio: 0.1, +} +``` -`assistant/chunk` remains the replay source for the committed attempt only. Hidden failed attempts are diagnostics, not model history. A stream that fails after committed chunks remains replayable as a thrown stream through the existing `llm-replay` sidecar mechanism, but the failure payload should become structured rather than `{ message, code, status? }`. +The retry-delay cap is deliberate. The survey found mixed precedent: Codex parses retry delays out of streamed OpenAI rate-limit error messages and uses that requested delay, but Codex also has finite stream retry counts; the official OpenAI and Anthropic TypeScript SDKs parse `retry-after-ms`, `Retry-After` seconds, and `Retry-After` dates and then sleep for the provider-specified duration; the official OpenAI and Anthropic Python SDKs only honor `Retry-After` when it is greater than zero and at most 60 seconds, otherwise falling back to ordinary exponential backoff. Because this RFC's default retry posture is unbounded, blindly honoring a multi-hour provider delay can make the agent look dead, while ignoring the hint entirely can retry too aggressively. The service should therefore record both `providerRetryAfterMs` and `scheduledRetryMs`, cap the scheduled sleep at `maxRetryDelayMs`, and surface that choice to the UI. + +The service cannot reliably infer whether the user is actively watching or away from the keyboard, so the default should not fail a retryable model call merely because a short interactive budget expired. A clear UI can make long waits tolerable even in interactive sessions: "retried 8 times; next retry in 10 minutes" is better than silently failing recoverable provider turbulence and asking the user to resubmit. + +### 5. Define the caller contract, not the product transcript + +This RFC is deliberately about the `dsh-llm` API and how callers use it, not about the final transcript/event architecture of the product. The LLM package should guarantee these caller-visible semantics: + +- `ctx.llm.stream()` is the live response-lifecycle API. It reports response starts, uncommitted assistant chunks, retries/backoff, interruptions/discards, terminal failures, and the one committed result. +- `response/committed` is the only event that makes model output safe for history or side effects. It carries the assembled `Message`, finish reason, usage, response id, route metadata, and provider ids known to the service. +- `response/failed` is the terminal event for classified failures. Callers should not need `try`/`catch` to learn that a provider was rate-limited, unavailable, misconfigured, aborted, or otherwise unable to produce a committed response. +- Response diagnostics are JSON-serializable so callers can store, display, or ignore them. The LLM package does not decide whether those diagnostics become session events, agent events, telemetry rows, or UI-only state. +- Convenience helpers such as `generate()` return a terminal union (`committed` or `failed`) rather than throwing for classified LLM failures. They must be derived from the lifecycle stream so recovery semantics stay single-sourced. + +The session log shape, agent event taxonomy, ACP rendering, snapshot/replay fixtures, and whether live uncommitted chunks are ever durably recorded are downstream integration decisions. This RFC should constrain them only by the LLM API contract above. ## Out of scope -This RFC does not propose silent mid-stream continuation after user-visible output. That requires a separate model-history design: either provider-supported prefill/continuation, a recovery prompt that explicitly shows the partial assistant output, or a UI affordance that marks the partial answer as failed and asks the model to continue in a new step. Splicing two API attempts into one assistant message is rejected. +This RFC does not propose silent mid-stream continuation after user-visible output. That requires a separate model-history design: either provider-supported prefill/continuation, a recovery prompt that explicitly shows the partial assistant output, or a UI affordance that marks the partial answer as failed and asks the model to continue in a new step. Splicing two API responses into one assistant message is rejected. This RFC also does not solve semantic model-output repair: malformed tool-call JSON, refusal handling, or content-filter fallbacks. Those may use the same failure vocabulary later, but they are higher-level agent behaviors, not unstable-API recovery. +This RFC does not decide whether product UIs consume LLM lifecycle events directly, through agent events, or through session events. It also does not decide which response diagnostics belong in the durable session log. Those decisions belong in narrower integration RFCs once the `dsh-llm` contract exists. + ## Acceptance criteria -- `LlmError` and in-band finish errors carry one structured `LlmFailure` payload; the agent loop persists that payload's serializable fields in error turn data. +- Adapter-thrown `LlmError`s and in-band finish errors carry one structured, JSON-serializable `LlmFailure` payload. - Recovery policy can distinguish retry, failover, credential/user-action, unsupported request, caller abort, adapter/protocol bug, and post-commit partial stream failure without parsing message text. -- The LLM service has an explicit API-attempt boundary; no retry path can append chunks from two provider attempts as one committed assistant step. -- The route registry allows multiple concrete API routes for one logical model and records the selected route on attempts/failures. -- Default recovery retries transient pre-commit failures with bounded backoff, honors provider retry-after hints, times out stuck streams, disables hidden SDK retries or surfaces them as attempts, and never retries after committed chunks without an explicit continuation design. -- Unit tests cover thrown errors and finish-error chunks through the real agent loop, retry-before-first-chunk, failover to a second compatible route, retry budget exhaustion, abort during backoff, stream timeout, and the "partial chunks then failure does not retry/splice" invariant. +- `ctx.llm.stream()` exposes the response lifecycle as the canonical stream, including terminal `response/failed` events for classified failures; convenience APIs are derived views for callers that only want the terminal outcome. +- `response/committed` carries the assembled assistant `Message`; callers do not need to rebuild committed output from lifecycle chunks. +- `generate()` returns a committed/failed union derived from the lifecycle stream, rather than throwing for classified LLM failures. +- The LLM service has an explicit API-response boundary; no retry path can present output from two provider responses as one committed assistant result. +- The route registry allows multiple concrete API routes for one logical model and records the selected route on responses/failures. +- Default recovery retries transient pre-commit failures with bounded backoff, honors provider retry-after hints, times out stuck streams, disables hidden SDK retries or surfaces them as response lifecycle events, and never retries after committed chunks without an explicit continuation design. +- Unit tests cover thrown errors and finish-error chunks through `dsh-llm`, retry-before-first-commit, failover to a second compatible route, unbounded retry status/backoff visibility, abort during backoff, stream timeout, and the "partial chunks then failure does not retry/splice" invariant. - Adapter tests classify representative HTTP statuses, retry-after headers, request ids, malformed/truncated SSE streams, SDK in-stream errors, caller aborts, and unsupported options into `LlmFailure`. -- Snapshot/replay support can faithfully represent a recovered call and a post-commit thrown stream without losing the structured failure payload. -- Docs updated in the same change: [the architecture LLM section](../../../architecture.md), [the LLM adapter cookbook](../../../cookbook/adding-an-llm-adapter.md), and the LLM package READMEs. +- Docs updated in the same change: [the architecture LLM section](../../../architecture.md), [the LLM adapter cookbook](../../../cookbook/adding-an-llm-adapter.md), and the LLM package README. ## Risks / what we give up -- **More surface area in the LLM core.** API recovery adds policy, route state, attempt diagnostics, and tests. That complexity belongs in `dsh-llm` because every consumer otherwise reinvents it around the same unsafe stream boundary. -- **Some modes reduce live streaming.** Buffered recovery trades first-token latency for safe retry. That should be opt-in or policy-driven; the default can still stream eagerly while retrying only before commit. -- **Breaking adapter churn.** Existing adapters will change from "stream chunks or throw a flat `LlmError`" to "stream one classified API attempt." Pre-release rules favor the correct seam over shims. +- **More surface area in the LLM core.** API recovery adds policy, route state, response diagnostics, and tests. That complexity belongs in `dsh-llm` because every consumer otherwise reinvents it around the same unsafe stream boundary. +- **Committed result lags live UI.** Safe recovery means callers cannot treat streamed tokens as final assistant output until the response commits. UIs can still stream eagerly from lifecycle assistant chunks, but side-effecting consumers must wait for `response/committed`. +- **Breaking adapter churn.** Existing adapters will change from "stream chunks or throw a flat `LlmError`" to "stream one classified API response." Pre-release rules favor the correct seam over shims. - **Route compatibility is easy to overclaim.** A route must advertise concrete capabilities, and failover must check the request actually fits them. "Same model name" is not enough when one route lacks strict tools, reasoning passback, images, stop sequences, or prefill. ## Related From 133f37584f1e9ad13796a0cd3f7c11b6d143f931 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:23:16 +0800 Subject: [PATCH 003/132] docs: reconcile LLM recovery RFC with sibling PRs --- .../2026-06-21-unstable-llm-api-recovery.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md index f5f7659c41..1f8f300509 100644 --- a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md +++ b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md @@ -88,7 +88,7 @@ type LlmStreamEvent = | { type: 'response/failed'; responseId?: ResponseId; failure: LlmFailure } | { type: 'response/committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } -type GenerateOutcome = +type LlmCallOutcome = | { type: 'committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } | { type: 'failed'; responseId?: ResponseId; failure: LlmFailure } ``` @@ -121,6 +121,10 @@ ctx.llm.registerRoute({ `GenerateOptions.model` remains the logical model. The service resolves it to a route for each API response, records the route in failure/response diagnostics, and can retry on the same route or fail over to another route with compatible capabilities. Duplicate model names become normal; duplicate route ids are the conflict. This is the smallest vocabulary that can express direct endpoint vs SDK-backed endpoint, regional endpoints, and future fallback models without making every caller own routing. +The route registry must keep the lifecycle guarantees of the current adapter registry: `registerRoute()` is effect-scoped, returns a disposer, and has an HMR-safety test proving disposal removes the route. It should not preserve `llm/adapter-change`; if [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82) lands first, that event is already gone, and the route registry should not reintroduce it without a concrete consumer. + +The new ids should follow the branded-id policy. `ResponseId`, `RouteId`, and the logical/wire model ids cross package boundaries and are easy to swap accidentally, so the implementation should deliberately brand or explicitly decline to brand each one in line with `2026-06-20-branded-ids` and its implementation stack ([PR #84](https://github.com/deepseek-ai/deepseek-harness/pull/84)). + ### 4. Put default API recovery policy in `dsh-llm` Adapters should not perform hidden SDK retries unless those retries are surfaced as response lifecycle events with classified failures. The service owns the default policy so every consumer gets the same behavior and the same audit trail. @@ -163,9 +167,9 @@ This RFC is deliberately about the `dsh-llm` API and how callers use it, not abo - `response/committed` is the only event that makes model output safe for history or side effects. It carries the assembled `Message`, finish reason, usage, response id, route metadata, and provider ids known to the service. - `response/failed` is the terminal event for classified failures. Callers should not need `try`/`catch` to learn that a provider was rate-limited, unavailable, misconfigured, aborted, or otherwise unable to produce a committed response. - Response diagnostics are JSON-serializable so callers can store, display, or ignore them. The LLM package does not decide whether those diagnostics become session events, agent events, telemetry rows, or UI-only state. -- Convenience helpers such as `generate()` return a terminal union (`committed` or `failed`) rather than throwing for classified LLM failures. They must be derived from the lifecycle stream so recovery semantics stay single-sourced. +- Any assembled convenience helper that survives or is reintroduced returns a terminal union (`committed` or `failed`) rather than throwing for classified LLM failures. It must be derived from the lifecycle stream so recovery semantics stay single-sourced. -The session log shape, agent event taxonomy, ACP rendering, snapshot/replay fixtures, and whether live uncommitted chunks are ever durably recorded are downstream integration decisions. This RFC should constrain them only by the LLM API contract above. +The session log shape, agent event taxonomy, ACP rendering, snapshot/replay fixtures, and whether live uncommitted chunks are ever durably recorded are downstream integration decisions. So is the fate of today's assembled public helper methods: [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82) implements the proposed removal of `generate()`, `streamBlocks()`, `GenerateResult`, and `llm/generate`, and this RFC should not resurrect them without a real caller. This RFC should constrain downstream work only by the LLM API contract above. ## Out of scope @@ -181,9 +185,11 @@ This RFC does not decide whether product UIs consume LLM lifecycle events direct - Recovery policy can distinguish retry, failover, credential/user-action, unsupported request, caller abort, adapter/protocol bug, and post-commit partial stream failure without parsing message text. - `ctx.llm.stream()` exposes the response lifecycle as the canonical stream, including terminal `response/failed` events for classified failures; convenience APIs are derived views for callers that only want the terminal outcome. - `response/committed` carries the assembled assistant `Message`; callers do not need to rebuild committed output from lifecycle chunks. -- `generate()` returns a committed/failed union derived from the lifecycle stream, rather than throwing for classified LLM failures. +- Any assembled convenience API that survives or is reintroduced returns a committed/failed union derived from the lifecycle stream, rather than throwing for classified LLM failures. - The LLM service has an explicit API-response boundary; no retry path can present output from two provider responses as one committed assistant result. - The route registry allows multiple concrete API routes for one logical model and records the selected route on responses/failures. +- `registerRoute()` is effect-scoped, returns a disposer, and has an HMR-safety test proving route cleanup; `llm/adapter-change` is not reintroduced unless a concrete consumer needs it. +- New LLM ids are deliberately branded or explicitly left unbranded according to the branded-id policy, with `ResponseId`, `RouteId`, and logical/wire model ids decided together. - Default recovery retries transient pre-commit failures with bounded backoff, honors provider retry-after hints, times out stuck streams, disables hidden SDK retries or surfaces them as response lifecycle events, and never retries after committed chunks without an explicit continuation design. - Unit tests cover thrown errors and finish-error chunks through `dsh-llm`, retry-before-first-commit, failover to a second compatible route, unbounded retry status/backoff visibility, abort during backoff, stream timeout, and the "partial chunks then failure does not retry/splice" invariant. - Adapter tests classify representative HTTP statuses, retry-after headers, request ids, malformed/truncated SSE streams, SDK in-stream errors, caller aborts, and unsupported options into `LlmFailure`. @@ -201,3 +207,5 @@ This RFC does not decide whether product UIs consume LLM lifecycle events direct - Builds on [Provider-neutral content-block vocabulary](../../implemented/architecture/2026-06-11-content-block-vocabulary.md): the content vocabulary stays provider-neutral; this adds a provider-neutral failure/recovery vocabulary beside it. - Revises the scope implied by [Two LLM adapters as a design-verification twin](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the twin validated chunk shape and error delivery paths, but it also exposed that delivery paths are not enough for unstable API recovery. - Extends [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md): `HarnessError.code` was the foundation; LLM API recovery needs a richer payload because retry/failover policy cannot safely branch on one flat string. +- Coordinates with [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82), which implements the `drop-unconsumed-llm-assembled-surfaces` and `drop-unconsumed-llm-adapter-change-event` simplification RFCs. If that PR lands first, this RFC starts from a narrower `dsh-llm`: no `generate()`, no `streamBlocks()`, no `GenerateResult`, no `llm/generate`, and no `llm/adapter-change`. Recovery should build on that baseline rather than revive removed convenience or change-notification surfaces speculatively. +- Coordinates with [PR #84](https://github.com/deepseek-ai/deepseek-harness/pull/84), which implements the branded-id RFC. The new response/route/model ids introduced here are exactly the sort of cross-boundary ids that need an explicit branding decision before implementation. From 5716fab2c5499ec2f4bbed80eb64bc6f1f82b6bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:27:53 +0800 Subject: [PATCH 004/132] docs: note in-flight RFC implementation stack --- .../architecture/2026-06-21-unstable-llm-api-recovery.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md index 1f8f300509..c1fc8703c8 100644 --- a/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md +++ b/docs/rfc/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md @@ -171,6 +171,8 @@ This RFC is deliberately about the `dsh-llm` API and how callers use it, not abo The session log shape, agent event taxonomy, ACP rendering, snapshot/replay fixtures, and whether live uncommitted chunks are ever durably recorded are downstream integration decisions. So is the fate of today's assembled public helper methods: [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82) implements the proposed removal of `generate()`, `streamBlocks()`, `GenerateResult`, and `llm/generate`, and this RFC should not resurrect them without a real caller. This RFC should constrain downstream work only by the LLM API contract above. +The current simplification stack was checked while drafting this proposal. [PR #83](https://github.com/deepseek-ai/deepseek-harness/pull/83) and [PR #85](https://github.com/deepseek-ai/deepseek-harness/pull/85) do not change this RFC's LLM API assumptions. [PR #86](https://github.com/deepseek-ai/deepseek-harness/pull/86) does matter for later integration because it folds durable token usage onto `assistant/message` and operational errors onto `turn/end.reason`; if it lands first, the LLM recovery implementation should still stop at the `dsh-llm` lifecycle contract here and let the agent/session layer decide how committed usage and terminal failures map onto those load-bearing product events. + ## Out of scope This RFC does not propose silent mid-stream continuation after user-visible output. That requires a separate model-history design: either provider-supported prefill/continuation, a recovery prompt that explicitly shows the partial assistant output, or a UI affordance that marks the partial answer as failed and asks the model to continue in a new step. Splicing two API responses into one assistant message is rejected. @@ -208,4 +210,6 @@ This RFC does not decide whether product UIs consume LLM lifecycle events direct - Revises the scope implied by [Two LLM adapters as a design-verification twin](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the twin validated chunk shape and error delivery paths, but it also exposed that delivery paths are not enough for unstable API recovery. - Extends [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md): `HarnessError.code` was the foundation; LLM API recovery needs a richer payload because retry/failover policy cannot safely branch on one flat string. - Coordinates with [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82), which implements the `drop-unconsumed-llm-assembled-surfaces` and `drop-unconsumed-llm-adapter-change-event` simplification RFCs. If that PR lands first, this RFC starts from a narrower `dsh-llm`: no `generate()`, no `streamBlocks()`, no `GenerateResult`, no `llm/generate`, and no `llm/adapter-change`. Recovery should build on that baseline rather than revive removed convenience or change-notification surfaces speculatively. +- Is orthogonal to [PR #81](https://github.com/deepseek-ai/deepseek-harness/pull/81), which proposes provider-request app attribution headers. Recovery route metadata and provider request construction can carry attribution policy later, but this RFC does not define request headers. - Coordinates with [PR #84](https://github.com/deepseek-ai/deepseek-harness/pull/84), which implements the branded-id RFC. The new response/route/model ids introduced here are exactly the sort of cross-boundary ids that need an explicit branding decision before implementation. +- Coordinates with [PR #86](https://github.com/deepseek-ai/deepseek-harness/pull/86), which implements the `collapse-trace-only-session-events` simplification RFC. If that stack lands first, downstream recovery integration should map committed usage and terminal error facts onto the surviving load-bearing session events instead of reintroducing standalone trace-only records from inside `dsh-llm`. From d2f810e9fe44c9484d0b889f0908f6e319d08341 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 15:38:47 +0800 Subject: [PATCH 005/132] feat(docs): build maintainable documentation site --- docs/AGENTS.md | 1 + docs/rfc/INDEX.md | 1 + ...026-07-13-documentation-site-projection.md | 39 + .../user}/zh-CN/develop/basic/config.md | 2 +- .../user}/zh-CN/develop/basic/index.md | 6 +- .../user}/zh-CN/develop/basic/tool.md | 2 +- .../user}/zh-CN/develop/framework/events.md | 2 +- .../user}/zh-CN/develop/framework/index.md | 4 +- .../user}/zh-CN/develop/framework/service.md | 16 +- .../user}/zh-CN/develop/practice/index.md | 4 +- .../zh-CN/develop/practice/llm-adapter.md | 0 docs/user/zh-CN/guide/config.md | 57 + {website => docs/user}/zh-CN/guide/index.md | 6 +- .../user}/zh-CN/guide/quickstart.md | 13 +- {website => docs/user}/zh-CN/index.md | 4 +- eslint.config.mjs | 5 +- knip.json | 10 + package.json | 9 +- pnpm-lock.yaml | 1565 ++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/project-doc-site.spec.ts | 98 ++ scripts/project-doc-site.ts | 215 +++ scripts/run-gates.ts | 1 + scripts/verify-md-wrap.ts | 24 +- tsconfig.json | 4 +- vitest.config.ts | 2 +- website/.gitignore | 5 +- website/.vitepress/config.ts | 142 ++ website/.vitepress/config/index.ts | 17 - website/.vitepress/config/zh-CN.ts | 99 -- website/docs.ts | 213 +++ website/package.json | 15 +- website/zh-CN/api/cordis/context.md | 85 - website/zh-CN/api/cordis/events.md | 120 -- website/zh-CN/api/cordis/fiber.md | 108 -- website/zh-CN/api/cordis/registry.md | 87 - website/zh-CN/api/cordis/service.md | 97 - website/zh-CN/api/harness/agent.md | 85 - website/zh-CN/api/harness/bash.md | 81 - website/zh-CN/api/harness/fs.md | 78 - website/zh-CN/api/harness/llm.md | 124 -- website/zh-CN/api/harness/session.md | 56 - website/zh-CN/api/harness/subagent.md | 85 - website/zh-CN/api/harness/tools.md | 122 -- website/zh-CN/api/index.md | 25 - website/zh-CN/design/composability.md | 72 - website/zh-CN/design/context-model.md | 129 -- website/zh-CN/design/effects-coeffects.md | 69 - website/zh-CN/design/index.md | 39 - website/zh-CN/design/reactive-coeffects.md | 90 - website/zh-CN/design/revertible-effects.md | 128 -- website/zh-CN/guide/config.md | 342 ---- 52 files changed, 2380 insertions(+), 2224 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md rename {website => docs/user}/zh-CN/develop/basic/config.md (96%) rename {website => docs/user}/zh-CN/develop/basic/index.md (95%) rename {website => docs/user}/zh-CN/develop/basic/tool.md (98%) rename {website => docs/user}/zh-CN/develop/framework/events.md (97%) rename {website => docs/user}/zh-CN/develop/framework/index.md (95%) rename {website => docs/user}/zh-CN/develop/framework/service.md (83%) rename {website => docs/user}/zh-CN/develop/practice/index.md (95%) rename {website => docs/user}/zh-CN/develop/practice/llm-adapter.md (100%) create mode 100644 docs/user/zh-CN/guide/config.md rename {website => docs/user}/zh-CN/guide/index.md (89%) rename {website => docs/user}/zh-CN/guide/quickstart.md (84%) rename {website => docs/user}/zh-CN/index.md (90%) create mode 100644 scripts/project-doc-site.spec.ts create mode 100644 scripts/project-doc-site.ts create mode 100644 website/.vitepress/config.ts delete mode 100644 website/.vitepress/config/index.ts delete mode 100644 website/.vitepress/config/zh-CN.ts create mode 100644 website/docs.ts delete mode 100644 website/zh-CN/api/cordis/context.md delete mode 100644 website/zh-CN/api/cordis/events.md delete mode 100644 website/zh-CN/api/cordis/fiber.md delete mode 100644 website/zh-CN/api/cordis/registry.md delete mode 100644 website/zh-CN/api/cordis/service.md delete mode 100644 website/zh-CN/api/harness/agent.md delete mode 100644 website/zh-CN/api/harness/bash.md delete mode 100644 website/zh-CN/api/harness/fs.md delete mode 100644 website/zh-CN/api/harness/llm.md delete mode 100644 website/zh-CN/api/harness/session.md delete mode 100644 website/zh-CN/api/harness/subagent.md delete mode 100644 website/zh-CN/api/harness/tools.md delete mode 100644 website/zh-CN/api/index.md delete mode 100644 website/zh-CN/design/composability.md delete mode 100644 website/zh-CN/design/context-model.md delete mode 100644 website/zh-CN/design/effects-coeffects.md delete mode 100644 website/zh-CN/design/index.md delete mode 100644 website/zh-CN/design/reactive-coeffects.md delete mode 100644 website/zh-CN/design/revertible-effects.md delete mode 100644 website/zh-CN/guide/config.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6824c71cc6..53f7d2f5b0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,6 +15,7 @@ Every fact has exactly one home — the tier whose job it is — and every other | [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | +| [user/](user/zh-CN/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 8dc3b93b47..7a44b009ad 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -160,6 +160,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Raise the Node LTS engine floor to 22.19](implemented/process/2026-07-06-node-engine-floor.md) | 2026-07-06 | | [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 | | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 | +| [Project canonical documentation into the website](implemented/process/2026-07-13-documentation-site-projection.md) | 2026-07-13 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md b/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md new file mode 100644 index 0000000000..5bdb0f87c0 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md @@ -0,0 +1,39 @@ +# RFC: Project canonical documentation into the website + +Status: implemented + +## Problem + +The repository needs a navigable documentation website without turning the website directory into a second documentation source. Copying package guides, architecture pages, or generated catalogs into a site-specific tree allows the two copies to drift, while pointing VitePress directly at the repository root couples public URLs and navigation to the internal file layout. Repository-relative links also need different destinations on the website: published pages stay inside the site, but source files and unpublished contributor documents belong on GitHub. + +## Decision + +Canonical Markdown remains in the repository tier that owns it. Product-facing guides live under `docs/user/`, generated reference remains in the existing generated catalogs, and architectural and cookbook pages remain at their existing `docs/` paths. + +`website/docs.ts` is an explicit publication manifest. Each entry maps one canonical source file to a stable public route, sidebar, section, and order. Adding or removing a published page is therefore a reviewable manifest change rather than an implicit directory crawl. + +`scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. + +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. + +Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. + +Site publication is separate from site construction. The repository contains local development and build commands, but no hosting or deployment workflow until a public destination is chosen. + +## Alternatives considered + +**Commit copied Markdown under `website/`.** This makes VitePress setup direct, but every copied guide or API table gains two owners and requires a synchronization convention that cannot identify which copy is authoritative. + +**Make `website/` the canonical home for every published page.** This keeps one copy but moves architecture, generated reference, and contributor-facing material away from their repository ownership tiers merely to satisfy a renderer. + +**Discover every Markdown file automatically.** This minimizes manifest maintenance but publishes internal documents accidentally, exposes source moves as URL changes, and produces navigation from incidental directory order. + +**Use filesystem symlinks.** Symlinks preserve a single source but do not solve public routing or repository-relative links, and their behavior is less predictable across local development, package tooling, and hosted CI environments. + +**Build only in a deployment workflow.** A deployment job can reveal rendering failures after merge. Keeping the production build in `doc-sync` makes the same failure visible locally and in ordinary CI even when no public deployment exists. + +## Consequences + +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. + +The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/website/zh-CN/develop/basic/config.md b/docs/user/zh-CN/develop/basic/config.md similarity index 96% rename from website/zh-CN/develop/basic/config.md rename to docs/user/zh-CN/develop/basic/config.md index 49bcc4ca77..6c5bf6c651 100644 --- a/website/zh-CN/develop/basic/config.md +++ b/docs/user/zh-CN/develop/basic/config.md @@ -105,4 +105,4 @@ export function apply(ctx: Context, config: Config) { ## 下一步 - [插件与生命周期](../framework/) — 深入了解插件的完整生命周期 -- [服务与依赖](../framework/service) — 让你的插件对外提供服务 +- [服务与依赖](../framework/service.md) — 让你的插件对外提供服务 diff --git a/website/zh-CN/develop/basic/index.md b/docs/user/zh-CN/develop/basic/index.md similarity index 95% rename from website/zh-CN/develop/basic/index.md rename to docs/user/zh-CN/develop/basic/index.md index 71d6962edd..ce68283892 100644 --- a/website/zh-CN/develop/basic/index.md +++ b/docs/user/zh-CN/develop/basic/index.md @@ -115,7 +115,7 @@ export default class MyService extends Service { } ``` -大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service))。 +大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service.md))。 ## 完整示例 @@ -144,5 +144,5 @@ export function apply(ctx: Context) { ## 下一步 -- [开发一个 Tool](./tool) — 详细了解 tool 定义 DSL -- [插件配置](./config) — 让插件接受用户配置 +- [开发一个 Tool](./tool.md) — 详细了解 tool 定义 DSL +- [插件配置](./config.md) — 让插件接受用户配置 diff --git a/website/zh-CN/develop/basic/tool.md b/docs/user/zh-CN/develop/basic/tool.md similarity index 98% rename from website/zh-CN/develop/basic/tool.md rename to docs/user/zh-CN/develop/basic/tool.md index 96d58da78d..47a3af7867 100644 --- a/website/zh-CN/develop/basic/tool.md +++ b/docs/user/zh-CN/develop/basic/tool.md @@ -195,5 +195,5 @@ export function apply(ctx: Context) { ## 下一步 -- [插件配置](./config) — 让你的 tool 可配置 +- [插件配置](./config.md) — 让你的 tool 可配置 - [能力三件套](../practice/) — 了解 seam/impl/consumer 模式 diff --git a/website/zh-CN/develop/framework/events.md b/docs/user/zh-CN/develop/framework/events.md similarity index 97% rename from website/zh-CN/develop/framework/events.md rename to docs/user/zh-CN/develop/framework/events.md index 0546fd68e7..641c63b0f8 100644 --- a/website/zh-CN/develop/framework/events.md +++ b/docs/user/zh-CN/develop/framework/events.md @@ -149,4 +149,4 @@ export function apply(ctx: Context) { ## 下一步 - [能力三件套](../practice/) — 事件在 capability seam 中的角色 -- [LLM 适配器](../practice/llm-adapter) — 实现一个完整的 LLM 后端 +- [LLM 适配器](../practice/llm-adapter.md) — 实现一个完整的 LLM 后端 diff --git a/website/zh-CN/develop/framework/index.md b/docs/user/zh-CN/develop/framework/index.md similarity index 95% rename from website/zh-CN/develop/framework/index.md rename to docs/user/zh-CN/develop/framework/index.md index 8d2f7c2b8a..b0547be61b 100644 --- a/website/zh-CN/develop/framework/index.md +++ b/docs/user/zh-CN/develop/framework/index.md @@ -135,5 +135,5 @@ effect cleaned up ## 下一步 -- [服务与依赖](./service) — 让你的插件对外提供能力 -- [事件系统](./events) — 插件间通信的核心机制 +- [服务与依赖](./service.md) — 让你的插件对外提供能力 +- [事件系统](./events.md) — 插件间通信的核心机制 diff --git a/website/zh-CN/develop/framework/service.md b/docs/user/zh-CN/develop/framework/service.md similarity index 83% rename from website/zh-CN/develop/framework/service.md rename to docs/user/zh-CN/develop/framework/service.md index 08d9a1b2c8..17b9cb4e4e 100644 --- a/website/zh-CN/develop/framework/service.md +++ b/docs/user/zh-CN/develop/framework/service.md @@ -127,21 +127,11 @@ export const inject = { optional: ['metrics'] } `plugin-a` 和 `plugin-b` 各自看到自己组内的 bash 实例,互不影响。 -## Harness 内置服务一览 +## Harness 内置服务 -| 服务名 | 提供者 | 用途 | -|--------|--------|------| -| `tools` | dsh-tools | Tool 注册表 | -| `llm` | dsh-llm | LLM 调用 + 适配器注册 | -| `agents` | dsh-agent | Agent 实例管理 | -| `session` | dsh-session | 会话事件流 | -| `systemPrompt` | dsh-system-prompt | 系统提示词组装 | -| `bash` | dsh-bash-local | Bash 命令执行 | -| `fs` | dsh-fs-local | 文件系统操作 | -| `subagent` | dsh-subagent | 子代理委派 | -| `persistence` | dsh-session-persistence | 会话持久化 | +服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 ## 下一步 -- [事件系统](./events) — 插件间松耦合通信 +- [事件系统](./events.md) — 插件间松耦合通信 - [能力三件套](../practice/) — 服务在 seam 模式中的应用 diff --git a/website/zh-CN/develop/practice/index.md b/docs/user/zh-CN/develop/practice/index.md similarity index 95% rename from website/zh-CN/develop/practice/index.md rename to docs/user/zh-CN/develop/practice/index.md index dd0ec1cb60..bffa35f964 100644 --- a/website/zh-CN/develop/practice/index.md +++ b/docs/user/zh-CN/develop/practice/index.md @@ -58,7 +58,7 @@ | 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | | Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | | 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | -| 压缩 | `dsh-compact` | `dsh-compact-basic` | (内置于 agent-loop) | +| 压缩 | `dsh-compact` | `dsh-compact-basic` | 由实现插件消费 agent-loop 的扩展事件 | ## 开发你自己的三件套 @@ -153,4 +153,4 @@ export function apply(ctx: Context) { ## 下一步 -- [LLM 适配器](./llm-adapter) — 实现一个 LLM 后端(最常见的 seam 扩展) +- [LLM 适配器](./llm-adapter.md) — 实现一个 LLM 后端(最常见的 seam 扩展) diff --git a/website/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md similarity index 100% rename from website/zh-CN/develop/practice/llm-adapter.md rename to docs/user/zh-CN/develop/practice/llm-adapter.md diff --git a/docs/user/zh-CN/guide/config.md b/docs/user/zh-CN/guide/config.md new file mode 100644 index 0000000000..5e13b69d9f --- /dev/null +++ b/docs/user/zh-CN/guide/config.md @@ -0,0 +1,57 @@ +# 配置文件 + +Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 + +## 从真实配置开始 + +仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: + +- [echo-agent](../../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 +- [coding-agent](../../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [acp-agent](../../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 + +最小配置由一组插件条目组成: + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## 插件条目 + +`name` 指定 npm 包或相对于 `cordis.yml` 的本地模块,`id` 为插件实例提供稳定标识,`config` 传入插件自己的配置。需要临时跳过某个条目时可设置 `disabled: true`。 + +```yaml +- id: local-tool + name: './src/my-tool.ts' + disabled: false + config: + toolName: my_tool +``` + +插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 + +## JavaScript 值和环境变量 + +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 + +```yaml +config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + cwd: !!js process.cwd() +``` + +标签是 `!!js`,不是 `!js`。 + +## 精确配置参考 + +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../../architecture.md)和[能力接口](../../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../../examples/README.md)中最接近的例子。 diff --git a/website/zh-CN/guide/index.md b/docs/user/zh-CN/guide/index.md similarity index 89% rename from website/zh-CN/guide/index.md rename to docs/user/zh-CN/guide/index.md index 8b7211b308..8c7f7e603a 100644 --- a/website/zh-CN/guide/index.md +++ b/docs/user/zh-CN/guide/index.md @@ -28,7 +28,7 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 2. 填写 API key 3. 运行 -不需要写任何代码。详见 [快速开始](./quickstart)。 +不需要写任何代码。详见 [快速开始](./quickstart.md)。 ### 插件开发者 @@ -41,7 +41,7 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 ## 技术栈 -- **运行时**: Node.js >= 24 +- **运行时**: Node.js ^22.19 或 >= 24 - **语言**: TypeScript (ESM) - **框架**: Cordis -- **包管理**: pnpm workspaces +- **包管理**: pnpm workspaces(仓库固定使用 pnpm 11) diff --git a/website/zh-CN/guide/quickstart.md b/docs/user/zh-CN/guide/quickstart.md similarity index 84% rename from website/zh-CN/guide/quickstart.md rename to docs/user/zh-CN/guide/quickstart.md index f15ac182cf..7ca4b19332 100644 --- a/website/zh-CN/guide/quickstart.md +++ b/docs/user/zh-CN/guide/quickstart.md @@ -4,13 +4,14 @@ ## 环境准备 -- [Node.js](https://nodejs.org/) >= 24 -- [pnpm](https://pnpm.io/) >= 9 +- [Node.js](https://nodejs.org/) ^22.19 或 >= 24 +- [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) ```sh # 确认版本 -node -v # v24.x 或更高 -pnpm -v # 9.x 或更高 +node -v # v22.19.x,或 v24.x 及更高版本 +corepack enable +pnpm -v # 11.x ``` ## 第一步:运行 echo-agent @@ -24,8 +25,6 @@ cd deepseek-harness # 安装依赖 pnpm install -# 如果看到 ERR_PNPM_IGNORED_BUILDS,可以忽略——安装已经成功了。 -# 想消除这个提示可以跑一次: pnpm approve-builds # 启动 echo-agent pnpm run demo:echo @@ -94,5 +93,5 @@ echo-agent 和 coding-agent 用的是同一个应用框架(`@deepseek-ai/dsh-std ## 下一步 -- [配置文件](./config) — 了解 `cordis.yml` 的完整语法 +- [配置文件](./config.md) — 了解 `cordis.yml` 的完整语法 - [开发插件](../develop/basic/) — 编写你自己的 tool 或后端 diff --git a/website/zh-CN/index.md b/docs/user/zh-CN/index.md similarity index 90% rename from website/zh-CN/index.md rename to docs/user/zh-CN/index.md index 90b23e483a..cbf700e41e 100644 --- a/website/zh-CN/index.md +++ b/docs/user/zh-CN/index.md @@ -7,10 +7,10 @@ hero: actions: - theme: brand text: 快速开始 - link: /zh-CN/guide/quickstart + link: /guide/quickstart - theme: alt text: 开发插件 - link: /zh-CN/develop/basic/ + link: /develop/basic/ features: - title: 插件化架构 details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。 diff --git a/eslint.config.mjs b/eslint.config.mjs index c62d3e9739..ab4c78ccb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,7 @@ export default tseslint.config( '**/.sessions/**', '.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources '**/.doc-typecheck-*/**', + 'website/.generated/**', 'vendor/**', // vendored source keeps upstream style and idioms '**/*.js', '**/*.mjs', @@ -33,7 +34,7 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], + files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -125,7 +126,7 @@ export default tseslint.config( // --- formatting (everything we own) ------------------------------------- { - files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'], + files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], plugins: { '@stylistic': stylistic }, rules: { '@stylistic/indent': ['error', 2], diff --git a/knip.json b/knip.json index cf43b90e34..88199c02a4 100644 --- a/knip.json +++ b/knip.json @@ -15,6 +15,16 @@ ], "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, + "website": { + "project": ["**/*.ts"], + "ignoreDependencies": [ + "@braintree/sanitize-url", + "cytoscape", + "cytoscape-cose-bilkent", + "dayjs", + "debug" + ] + }, "packages/*/*": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/package.json b/package.json index 0fc57b7db5..3a1e49391c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "workspaces": [ "vendor/*", - "packages/*/*" + "packages/*/*", + "website" ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", @@ -44,6 +45,10 @@ "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", + "docs:dev": "pnpm --filter @deepseek-ai/website run dev", + "docs:build": "pnpm --filter @deepseek-ai/website run build", + "docs:preview": "pnpm --filter @deepseek-ai/website run preview", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", @@ -62,7 +67,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run docs:check", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bc2ed1495..360ddc7e7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,31 +75,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/ui/user-approval: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/bash/bash: devDependencies: '@deepseek-ai/dsh-brand': @@ -164,9 +139,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -194,6 +166,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -425,9 +400,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime @@ -440,6 +412,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -1135,9 +1110,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -1186,6 +1158,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction @@ -1310,6 +1285,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/user-approval: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/user-interaction: devDependencies: '@deepseek-ai/dsh-agent': @@ -1654,6 +1654,36 @@ importers: specifier: ^1.8.1 version: 1.8.1 + website: + devDependencies: + '@braintree/sanitize-url': + specifier: 7.1.2 + version: 7.1.2 + cytoscape: + specifier: 3.34.0 + version: 3.34.0 + cytoscape-cose-bilkent: + specifier: 4.1.0 + version: 4.1.0(cytoscape@3.34.0) + dayjs: + specifier: 1.11.21 + version: 1.11.21 + debug: + specifier: 4.4.3 + version: 4.4.3 + mermaid: + specifier: 11.16.0 + version: 11.16.0 + vite: + specifier: ^5.4.14 + version: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3)) + packages: '@agentclientprotocol/sdk@0.25.1': @@ -1661,6 +1691,82 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@algolia/abtesting@1.21.2': + resolution: {integrity: sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.55.2': + resolution: {integrity: sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.55.2': + resolution: {integrity: sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.55.2': + resolution: {integrity: sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.55.2': + resolution: {integrity: sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.55.2': + resolution: {integrity: sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.55.2': + resolution: {integrity: sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.55.2': + resolution: {integrity: sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.55.2': + resolution: {integrity: sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.55.2': + resolution: {integrity: sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.55.2': + resolution: {integrity: sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.55.2': + resolution: {integrity: sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.55.2': + resolution: {integrity: sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.55.2': + resolution: {integrity: sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw==} + engines: {node: '>= 14.0.0'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -1839,6 +1945,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -1901,6 +2010,29 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@earendil-works/pi-ai@0.79.3': resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} engines: {node: '>=22.19.0'} @@ -1924,102 +2056,204 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -2032,6 +2266,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -2044,6 +2284,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -2056,24 +2302,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2148,6 +2418,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/simple-icons@1.2.89': + resolution: {integrity: sha512-hRaCY5s2G5oWAIhc4LCGYn6g6RrwLL4zhoLOT+KUO3joVCxVlZKA+839bv/47Nbe9/ZD4UA6dznZ4XPYcI53wA==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -2167,6 +2440,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} @@ -2645,6 +2921,168 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -2811,6 +3249,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} @@ -2820,9 +3261,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2847,6 +3297,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@8.61.0': resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2906,9 +3359,19 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.8': resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: @@ -2947,6 +3410,94 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2964,6 +3515,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + algoliasearch@5.55.2: + resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} + engines: {node: '>= 14.0.0'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2998,6 +3553,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -3022,6 +3580,12 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -3029,6 +3593,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -3040,6 +3607,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + cordis@4.0.0-rc.6: resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} hasBin: true @@ -3069,6 +3640,9 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -3290,10 +3864,17 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3304,6 +3885,11 @@ packages: es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3363,6 +3949,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3428,6 +4017,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3479,6 +4071,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3489,6 +4090,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3538,6 +4142,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3783,6 +4391,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3818,6 +4429,9 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -3918,6 +4532,12 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3958,10 +4578,16 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -4024,6 +4650,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4041,10 +4670,21 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.4: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} @@ -4068,6 +4708,15 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4079,6 +4728,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -4111,6 +4763,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -4134,6 +4791,9 @@ packages: schemastery@3.18.0: resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -4147,6 +4807,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4158,12 +4821,22 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -4174,6 +4847,10 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4185,6 +4862,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4219,6 +4899,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -4324,6 +5007,9 @@ packages: unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -4340,11 +5026,48 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4388,6 +5111,24 @@ packages: yaml: optional: true + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4429,6 +5170,14 @@ packages: jsdom: optional: true + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4516,6 +5265,118 @@ snapshots: dependencies: zod: 4.4.3 + '@algolia/abtesting@1.21.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/client-abtesting@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-analytics@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-common@5.55.2': {} + + '@algolia/client-insights@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-personalization@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-query-suggestions@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-search@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/ingestion@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/monitoring@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/recommend@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/requester-browser-xhr@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-fetch@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-node-http@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -4815,6 +5676,9 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@6.0.4': + optional: true + '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -4864,6 +5728,31 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) + preact: 10.29.7 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@docsearch/css': 3.8.2 + algoliasearch: 5.55.2 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -4916,81 +5805,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -5053,6 +6011,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/simple-icons@1.2.89': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': @@ -5075,6 +6037,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -5359,6 +6332,121 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -5566,6 +6654,10 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/jsdom@28.0.3': dependencies: '@types/node': 25.9.3 @@ -5577,10 +6669,19 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@22.20.0': @@ -5602,6 +6703,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5693,11 +6796,18 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3))': + dependencies: + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5761,6 +6871,105 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@6.0.3) + + '@vue/shared@3.5.39': {} + + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5776,6 +6985,23 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + algoliasearch@5.55.2: + dependencies: + '@algolia/abtesting': 1.21.2 + '@algolia/client-abtesting': 5.55.2 + '@algolia/client-analytics': 5.55.2 + '@algolia/client-common': 5.55.2 + '@algolia/client-insights': 5.55.2 + '@algolia/client-personalization': 5.55.2 + '@algolia/client-query-suggestions': 5.55.2 + '@algolia/client-search': 5.55.2 + '@algolia/ingestion': 1.55.2 + '@algolia/monitoring': 1.55.2 + '@algolia/recommend': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + ansis@4.3.1: {} anynum@1.0.0: {} @@ -5806,6 +7032,8 @@ snapshots: bignumber.js@9.3.1: {} + birpc@2.9.0: {} + birpc@4.0.0: {} bowser@2.14.1: {} @@ -5822,18 +7050,28 @@ snapshots: chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 + comma-separated-tokens@2.0.3: {} + commander@7.2.0: {} commander@8.3.0: {} convert-source-map@2.0.0: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4): dependencies: '@standard-schema/spec': 1.1.0 @@ -5871,6 +7109,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): dependencies: cose-base: 1.0.3 @@ -6106,14 +7346,44 @@ snapshots: dependencies: safe-buffer: 5.2.1 + emoji-regex-xs@1.0.0: {} + empathic@2.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} es-module-lexer@2.1.0: {} es-toolkit@1.49.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -6219,6 +7489,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -6280,6 +7552,10 @@ snapshots: flatted@3.4.2: {} + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -6338,6 +7614,26 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0: @@ -6348,6 +7644,8 @@ snapshots: html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6388,6 +7686,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-what@5.5.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6620,6 +7920,8 @@ snapshots: dependencies: semver: 7.8.4 + mark.js@8.11.1: {} + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -6710,6 +8012,18 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -6947,6 +8261,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -6974,8 +8292,17 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + non-layered-tidy-tree-layout@2.0.2: + optional: true + obug@2.1.3: {} + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -7068,6 +8395,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -7085,8 +8414,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.7: {} + prelude-ls@1.2.1: {} + property-information@7.2.0: {} + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7116,12 +8449,24 @@ snapshots: readdirp@4.1.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} retry@0.13.1: {} + rfdc@1.4.1: {} + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): @@ -7182,6 +8527,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.1 '@rolldown/binding-win32-x64-msvc': 1.1.1 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -7208,6 +8584,8 @@ snapshots: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 + search-insights@2.17.3: {} + semver@7.8.4: {} shebang-command@2.0.0: @@ -7216,16 +8594,36 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} smol-toml@1.6.1: {} source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + stackback@0.0.2: {} std-env@4.1.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -7234,6 +8632,10 @@ snapshots: stylis@4.4.0: {} + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -7242,6 +8644,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -7269,6 +8673,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -7352,6 +8758,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -7373,6 +8783,16 @@ snapshots: uuid@14.0.1: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: debug: 4.4.3 @@ -7383,6 +8803,16 @@ snapshots: - supports-color - typescript + vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -7413,6 +8843,63 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitepress-plugin-mermaid@2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3)): + dependencies: + mermaid: 11.16.0 + vitepress: 1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.89 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.39 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -7471,6 +8958,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.39(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@6.0.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 6.0.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6407a99f52..3572d7368a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - vendor/* - packages/*/* + - website peerDependencyRules: allowedVersions: diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts new file mode 100644 index 0000000000..f44b779d80 --- /dev/null +++ b/scripts/project-doc-site.spec.ts @@ -0,0 +1,98 @@ +/** Tests for the documentation website projection adapter. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { DocsPage } from '../website/docs.ts' +import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(): { root: string; pages: DocsPage[] } { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-')) + roots.push(root) + mkdirSync(join(root, 'docs'), { recursive: true }) + mkdirSync(join(root, 'packages'), { recursive: true }) + writeFileSync(join(root, 'docs/a.md'), '# A\n') + writeFileSync(join(root, 'docs/b.md'), '# B\n') + writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n') + writeFileSync(join(root, 'packages/logo.svg'), '\n') + return { + root, + pages: [ + { source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-docs', section: 'Test', order: 1 }, + { source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-docs', section: 'Test', order: 2 }, + ], + } +} + +describe('rewriteMarkdown', () => { + it('maps published pages and pins unpublished source links', () => { + const { root, pages } = fixture() + const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n' + expect(rewriteMarkdown(source, { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe( + '[B](./reference/b.md#part) ' + + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + + '[web](https://example.com)\n', + ) + }) + + it('uses raw GitHub content for unpublished images', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') + }) + + it('does not rewrite Markdown-looking text inside code fences', () => { + const { root, pages } = fixture() + const source = '```md\n[B](b.md)\n```\n' + expect(rewriteMarkdown(source, { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe(source) + }) + + it('fails loud when a relative target is missing', () => { + const { root, pages } = fixture() + expect(() => rewriteMarkdown('[missing](missing.md)\n', { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toThrow('links to missing path "missing.md"') + }) +}) + +describe('addProjectionFrontmatter', () => { + it('adds frontmatter to an ordinary Markdown page', () => { + expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe( + '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n', + ) + }) + + it('extends existing VitePress frontmatter', () => { + expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe( + '---\neditSource: "docs/index.md"\nlayout: home\n---\n', + ) + }) +}) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts new file mode 100644 index 0000000000..89e35dfdc8 --- /dev/null +++ b/scripts/project-doc-site.ts @@ -0,0 +1,215 @@ +/** + * Build-time projection from canonical repository Markdown into VitePress. + * + * The generated tree is disposable: sources stay in their owning `docs/` + * tier, while this adapter rewrites cross-source links for the public site. + */ + +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, extname, posix, relative, resolve, sep } from 'node:path' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' +import { docsPages, type DocsPage } from '../website/docs.ts' + +const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' +const root = resolve(import.meta.dirname, '..') +const generatedRoot = resolve(root, 'website/.generated') + +interface Replacement { + start: number + end: number + value: string +} + +/** Inputs for rewriting one canonical Markdown page. */ +export interface RewriteMarkdownOptions { + sourcePath: string + route: string + pages: DocsPage[] + repoRoot: string + repositoryRef: string +} + +function repoPath(absPath: string, repoRoot: string): string { + return relative(repoRoot, absPath).split(sep).join('/') +} + +function isExternalOrSiteAbsolute(url: string): boolean { + return url.startsWith('#') + || url.startsWith('//') + || url.startsWith('/') + || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) +} + +function splitTarget(url: string): { path: string; suffix: string } { + const boundary = url.search(/[?#]/) + if (boundary === -1) return { path: url, suffix: '' } + return { path: url.slice(0, boundary), suffix: url.slice(boundary) } +} + +function decodePath(path: string): string { + try { + return decodeURIComponent(path) + } catch { + throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`) + } +} + +function routeTarget(fromRoute: string, toRoute: string, suffix: string): string { + const target = posix.relative(posix.dirname(fromRoute), toRoute) + return `${target.startsWith('.') ? target : `./${target}`}${suffix}` +} + +function sourceMap(pages: DocsPage[]): Map { + const map = new Map() + for (const page of pages) { + for (const source of [page.source, ...(page.sourceAliases ?? [])]) { + if (map.has(source)) { + throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)}.`) + } + map.set(source, page) + } + } + return map +} + +function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } { + const decoded = decodePath(rawPath) + let absPath = resolve(dirname(sourceAbs), decoded) + if (existsSync(absPath)) return { absPath } + + const lineMatch = decoded.match(/:(\d+)$/) + if (lineMatch !== null) { + const lineText = lineMatch[1] + if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.') + absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length)) + if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) } + } + + if (extname(decoded) === '') { + const markdown = resolve(dirname(sourceAbs), `${decoded}.md`) + if (existsSync(markdown)) return { absPath: markdown } + const index = resolve(dirname(sourceAbs), decoded, 'index.md') + if (existsSync(index)) return { absPath: index } + } + + throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`) +} + +function githubTarget( + absPath: string, + line: number | undefined, + suffix: string, + repositoryRef: string, + repoRoot: string, + image: boolean, +): string { + const path = repoPath(absPath, repoRoot) + if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}` + const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' + const lineSuffix = line === undefined ? suffix : `#L${line}` + return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` +} + +/** + * Rewrite repository-relative links without reserializing Markdown. + * + * @param source Markdown text from the canonical file. + * @param options Source, route, manifest, and repository context. + * @returns Markdown whose published links resolve inside the site or to GitHub. + */ +export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string { + const sourceAbs = resolve(options.repoRoot, options.sourcePath) + const published = sourceMap(options.pages) + const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const replacements: Replacement[] = [] + + const rewrite = (node: Nodes & { url: string }): void => { + if (isExternalOrSiteAbsolute(node.url)) return + const { path, suffix } = splitTarget(node.url) + if (path === '') return + const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) + const targetPath = repoPath(absPath, options.repoRoot) + const page = published.get(targetPath) + const nextUrl = page === undefined + ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') + : routeTarget(options.route, page.route, suffix) + + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) { + throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) + } + const rawNode = source.slice(start, end) + const urlOffset = rawNode.lastIndexOf(node.url) + if (urlOffset === -1) { + throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`) + } + replacements.push({ + start: start + urlOffset, + end: start + urlOffset + node.url.length, + value: nextUrl, + }) + } + + const visit = (node: Nodes): void => { + if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node) + if ('children' in node) { + for (const child of node.children) visit(child) + } + } + visit(tree) + + let projected = source + for (const replacement of replacements.sort((a, b) => b.start - a.start)) { + projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end) + } + return projected +} + +/** + * Record the canonical edit target in VitePress frontmatter. + * + * @param markdown Projected Markdown content. + * @param sourcePath Repository-relative canonical source path. + * @returns Markdown with an `editSource` frontmatter field. + */ +export function addProjectionFrontmatter(markdown: string, sourcePath: string): string { + const field = `editSource: ${JSON.stringify(sourcePath)}` + if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`) + return `---\n${field}\n---\n\n${markdown}` +} + +/** Canonical Markdown files watched by the local VitePress dev server. */ +export function docsSourceFiles(): string[] { + return [...new Set(docsPages.map(page => resolve(root, page.source)))] +} + +/** Rebuild the disposable VitePress source tree from the publication manifest. */ +export function projectDocs(): void { + const routes = new Set() + const repositoryRef = process.env.GITHUB_SHA ?? 'master' + rmSync(generatedRoot, { recursive: true, force: true }) + + for (const page of docsPages) { + if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) + routes.add(page.route) + const sourceAbs = resolve(root, page.source) + if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) { + throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) + } + const output = resolve(generatedRoot, page.route) + mkdirSync(dirname(output), { recursive: true }) + const markdown = readFileSync(sourceAbs, 'utf8') + const projected = rewriteMarkdown(markdown, { + sourcePath: page.source, + route: page.route, + pages: docsPages, + repoRoot: root, + repositoryRef, + }) + writeFileSync(output, addProjectionFrontmatter(projected, page.source)) + } +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ef1e07ea43..c9c9013100 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -272,6 +272,7 @@ function docSyncLeafGates(): Gate[] { pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), + pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), ] } diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 3143ee5617..65bf35fb03 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -10,10 +10,12 @@ * `paragraph` node whose source span covers more than one line. The parser owns * all the structure that legitimately occupies multiple lines — fenced code * (any fence length), tables, list items, blockquotes, HTML blocks, headings, - * thematic breaks, link-reference definitions — so a hard wrap is simply "a - * paragraph node that starts and ends on different lines." This is checker, not - * formatter: it reports and never rewrites, so it introduces zero cosmetic - * churn (no emphasis-marker or table-delimiter normalization). + * thematic breaks, link-reference definitions — while a small preprocessing + * pass masks VitePress YAML frontmatter and custom-container delimiter lines. + * A hard wrap is simply "a paragraph node that starts and ends on different + * lines." This is checker, not formatter: it reports and never rewrites, so it + * introduces zero cosmetic churn (no emphasis-marker or table-delimiter + * normalization). * * A wrapped paragraph inside a list item or blockquote is still a `paragraph` * node, so those are caught too. Scope mirrors doc-typecheck plus the two @@ -57,11 +59,23 @@ interface Violation { text: string } +function maskVitePressStructure(source: string): string { + const lines = source.split('\n') + if (lines[0] === '---') { + const closing = lines.indexOf('---', 1) + if (closing !== -1) { + for (let index = 0; index <= closing; index++) lines[index] = '' + } + } + return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n') +} + /** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */ function findViolations(absPath: string): Violation[] { const file = relative(root, absPath) const source = readFileSync(absPath, 'utf8') - const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const parsedSource = maskVitePressStructure(source) + const tree = fromMarkdown(parsedSource, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) const out: Violation[] = [] const visit = (node: Nodes): void => { diff --git a/tsconfig.json b/tsconfig.json index 70780e1c17..800fd39318 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,9 @@ "examples/*/start.ts", "examples/*/tests/**/*.ts", "packages/*/*/tests/**/*.ts", - "scripts/**/*.ts" + "scripts/**/*.ts", + "website/**/*.ts", + "website/.vitepress/**/*.ts" ], "references": [ { "path": "./vendor/cosmokit" }, diff --git a/vitest.config.ts b/vitest.config.ts index 11d1454b08..459147f4a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ // instead applies the one root map to every importer. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { - include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], + include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no diff --git a/website/.gitignore b/website/.gitignore index 2c1fa99cb4..29099c8fe6 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -1,3 +1,4 @@ node_modules/ -.vitepress/dist/ -.vitepress/cache/ +.cache/ +.dist/ +.generated/ diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts new file mode 100644 index 0000000000..71c276794a --- /dev/null +++ b/website/.vitepress/config.ts @@ -0,0 +1,142 @@ +/** VitePress configuration for the locally projected documentation site. */ + +import type { DefaultTheme, PageData } from 'vitepress' +import type { ViteDevServer } from 'vite' +import { withMermaid } from 'vitepress-plugin-mermaid' +import { docsPages, type DocsPage } from '../docs.ts' +import { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts' + +projectDocs() + +const sectionOrder = [ + '入门', + '基础', + '框架能力', + '实战', + 'Concepts', + 'Generated reference', + 'Data structures', + 'Cookbook', +] + +function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { + const pages = docsPages.filter(page => page.sidebar === collection && page.route !== 'index.md') + const sections = new Map() + for (const page of pages) { + const entries = sections.get(page.section) ?? [] + entries.push(page) + sections.set(page.section, entries) + } + return [...sections.entries()] + .sort(([left], [right]) => sectionOrder.indexOf(left) - sectionOrder.indexOf(right)) + .map(([text, entries]) => ({ + text, + items: entries + .sort((left, right) => left.order - right.order) + .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), + })) +} + +function watchCanonicalDocs(server: ViteDevServer): void { + const sources = docsSourceFiles() + server.watcher.add(sources) + server.watcher.on('change', (changed) => { + if (!sources.includes(changed)) return + projectDocs() + }) +} + +function escapeVueInterpolation(html: string): string { + return html.replaceAll('{{', '{{').replaceAll('}}', '}}') +} + +const sharedTheme: Pick = { + search: { provider: 'local' }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, + ], + editLink: { + pattern: ({ frontmatter }: PageData) => { + const data: unknown = frontmatter + const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined + if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') + return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + }, + text: '在 GitHub 上编辑此页', + }, +} + +export default withMermaid({ + title: 'DeepSeek Harness', + description: '用于构建 Agent Harness 的插件化 SDK', + cleanUrls: true, + srcDir: '.generated', + cacheDir: '.cache', + outDir: '.dist', + locales: { + root: { + label: '简体中文', + lang: 'zh-CN', + themeConfig: { + nav: [ + { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, + { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, + { text: 'Reference', link: '/en/', activeMatch: '^/en/' }, + ], + sidebar: { + '/guide/': sidebar('zh-guide'), + '/develop/': sidebar('zh-develop'), + }, + outline: { label: '本页目录' }, + docFooter: { prev: '上一篇', next: '下一篇' }, + }, + }, + en: { + label: 'English', + lang: 'en-US', + link: '/en/', + themeConfig: { + nav: [ + { text: 'Concepts', link: '/en/' }, + { text: 'Reference', link: '/en/config-catalog' }, + { text: '中文指南', link: '/guide/' }, + ], + sidebar: { + '/en/': sidebar('en-docs'), + }, + editLink: { + pattern: ({ frontmatter }: PageData) => { + const data: unknown = frontmatter + const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined + if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') + return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + }, + text: 'Edit this page on GitHub', + }, + outline: { label: 'On this page' }, + docFooter: { prev: 'Previous', next: 'Next' }, + }, + }, + }, + vite: { + plugins: [ + { + name: 'deepseek-harness-doc-projector', + configureServer: watchCanonicalDocs, + }, + ], + }, + markdown: { + config(md) { + const renderText = md.renderer.rules.text + const renderCode = md.renderer.rules.code_inline + if (renderText === undefined || renderCode === undefined) { + throw new Error('VitePress Markdown renderer is missing its text or inline-code rule.') + } + md.renderer.rules.text = (...args) => escapeVueInterpolation(renderText(...args)) + md.renderer.rules.code_inline = (...args) => escapeVueInterpolation(renderCode(...args)) + }, + }, + mermaid: {}, + themeConfig: sharedTheme, +}) diff --git a/website/.vitepress/config/index.ts b/website/.vitepress/config/index.ts deleted file mode 100644 index b4978ca4aa..0000000000 --- a/website/.vitepress/config/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'vitepress' -import { zhCN } from './zh-CN' - -export default defineConfig({ - title: 'DeepSeek Harness', - description: '插件化 Agent 开发框架', - - locales: { - 'zh-CN': zhCN, - }, - - themeConfig: { - socialLinks: [ - { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, - ], - }, -}) diff --git a/website/.vitepress/config/zh-CN.ts b/website/.vitepress/config/zh-CN.ts deleted file mode 100644 index 83767b6cbc..0000000000 --- a/website/.vitepress/config/zh-CN.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' - -const guideSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '入门', - items: [ - { text: '介绍', link: '/zh-CN/guide/' }, - { text: '快速开始', link: '/zh-CN/guide/quickstart' }, - { text: '配置文件', link: '/zh-CN/guide/config' }, - ], - }, -] - -const developSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '基础', - items: [ - { text: '第一个插件', link: '/zh-CN/develop/basic/' }, - { text: '开发一个 Tool', link: '/zh-CN/develop/basic/tool' }, - { text: '插件配置', link: '/zh-CN/develop/basic/config' }, - ], - }, - { - text: '框架能力', - items: [ - { text: '插件与生命周期', link: '/zh-CN/develop/framework/' }, - { text: '服务与依赖', link: '/zh-CN/develop/framework/service' }, - { text: '事件系统', link: '/zh-CN/develop/framework/events' }, - ], - }, - { - text: '实战', - items: [ - { text: '能力的三层拆分', link: '/zh-CN/develop/practice/' }, - { text: 'LLM 适配器', link: '/zh-CN/develop/practice/llm-adapter' }, - ], - }, -] - -const apiSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '框架 API', - items: [ - { text: '总览', link: '/zh-CN/api/' }, - { text: 'Context', link: '/zh-CN/api/cordis/context' }, - { text: 'Events', link: '/zh-CN/api/cordis/events' }, - { text: 'Fiber', link: '/zh-CN/api/cordis/fiber' }, - { text: 'Registry', link: '/zh-CN/api/cordis/registry' }, - { text: 'Service', link: '/zh-CN/api/cordis/service' }, - ], - }, - { - text: 'Harness API', - items: [ - { text: 'Tools (dsh-tools)', link: '/zh-CN/api/harness/tools' }, - { text: 'LLM (dsh-llm)', link: '/zh-CN/api/harness/llm' }, - { text: 'Session (dsh-session)', link: '/zh-CN/api/harness/session' }, - { text: 'Agent (dsh-agent)', link: '/zh-CN/api/harness/agent' }, - { text: 'Bash (dsh-bash)', link: '/zh-CN/api/harness/bash' }, - { text: 'Filesystem (dsh-fs)', link: '/zh-CN/api/harness/fs' }, - { text: 'Subagent (dsh-subagent)', link: '/zh-CN/api/harness/subagent' }, - ], - }, -] - -const designSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '系统设计', - items: [ - { text: '概述', link: '/zh-CN/design/' }, - { text: '可组合性与插件系统', link: '/zh-CN/design/composability' }, - { text: '作用与余作用', link: '/zh-CN/design/effects-coeffects' }, - { text: '可逆作用', link: '/zh-CN/design/revertible-effects' }, - { text: '响应式余作用', link: '/zh-CN/design/reactive-coeffects' }, - { text: '上下文模型', link: '/zh-CN/design/context-model' }, - ], - }, -] - -export const zhCN: LocaleSpecificConfig = { - label: '简体中文', - lang: 'zh-CN', - themeConfig: { - nav: [ - { text: '入门', link: '/zh-CN/guide/', activeMatch: '/zh-CN/guide/' }, - { text: '开发', link: '/zh-CN/develop/basic/', activeMatch: '/zh-CN/develop/' }, - { text: 'API', link: '/zh-CN/api/', activeMatch: '/zh-CN/api/' }, - { text: '设计', link: '/zh-CN/design/', activeMatch: '/zh-CN/design/' }, - ], - sidebar: { - '/zh-CN/guide/': guideSidebar, - '/zh-CN/develop/': developSidebar, - '/zh-CN/api/': apiSidebar, - '/zh-CN/design/': designSidebar, - }, - outline: { label: '本页目录' }, - docFooter: { prev: '上一篇', next: '下一篇' }, - }, -} diff --git a/website/docs.ts b/website/docs.ts new file mode 100644 index 0000000000..53e74c2905 --- /dev/null +++ b/website/docs.ts @@ -0,0 +1,213 @@ +/** + * Canonical publication manifest for the documentation website. + * + * Markdown stays in its owning repository tier. This manifest only maps a + * source file to its public route and navigation placement. + */ + +/** A page projected into the VitePress source tree. */ +export interface DocsPage { + /** Repository-relative canonical Markdown source. */ + source: string + /** VitePress route, including the `.md` suffix. */ + route: string + /** Navigation label shown in the sidebar. */ + label: string + /** Sidebar collection that owns the page. */ + sidebar: 'zh-guide' | 'zh-develop' | 'en-docs' + /** Section label within the sidebar. */ + section: string + /** Stable order within the section. */ + order: number + /** Additional repository paths that resolve to this page. */ + sourceAliases?: string[] +} + +const zhGuide: DocsPage[] = [ + { + source: 'docs/user/zh-CN/index.md', + route: 'index.md', + label: 'DeepSeek Harness', + sidebar: 'zh-guide', + section: '入门', + order: 0, + }, + { + source: 'docs/user/zh-CN/guide/index.md', + route: 'guide/index.md', + label: '介绍', + sidebar: 'zh-guide', + section: '入门', + order: 1, + sourceAliases: ['docs/user/zh-CN/guide'], + }, + { + source: 'docs/user/zh-CN/guide/quickstart.md', + route: 'guide/quickstart.md', + label: '快速开始', + sidebar: 'zh-guide', + section: '入门', + order: 2, + }, + { + source: 'docs/user/zh-CN/guide/config.md', + route: 'guide/config.md', + label: '配置文件', + sidebar: 'zh-guide', + section: '入门', + order: 3, + }, +] + +const zhDevelop: DocsPage[] = [ + { + source: 'docs/user/zh-CN/develop/basic/index.md', + route: 'develop/basic/index.md', + label: '第一个插件', + sidebar: 'zh-develop', + section: '基础', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/basic'], + }, + { + source: 'docs/user/zh-CN/develop/basic/tool.md', + route: 'develop/basic/tool.md', + label: '开发一个 Tool', + sidebar: 'zh-develop', + section: '基础', + order: 2, + }, + { + source: 'docs/user/zh-CN/develop/basic/config.md', + route: 'develop/basic/config.md', + label: '插件配置', + sidebar: 'zh-develop', + section: '基础', + order: 3, + }, + { + source: 'docs/user/zh-CN/develop/framework/index.md', + route: 'develop/framework/index.md', + label: '插件与生命周期', + sidebar: 'zh-develop', + section: '框架能力', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/framework'], + }, + { + source: 'docs/user/zh-CN/develop/framework/service.md', + route: 'develop/framework/service.md', + label: '服务与依赖', + sidebar: 'zh-develop', + section: '框架能力', + order: 2, + }, + { + source: 'docs/user/zh-CN/develop/framework/events.md', + route: 'develop/framework/events.md', + label: '事件系统', + sidebar: 'zh-develop', + section: '框架能力', + order: 3, + }, + { + source: 'docs/user/zh-CN/develop/practice/index.md', + route: 'develop/practice/index.md', + label: '能力的三层拆分', + sidebar: 'zh-develop', + section: '实战', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/practice'], + }, + { + source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', + route: 'develop/practice/llm-adapter.md', + label: 'LLM 适配器', + sidebar: 'zh-develop', + section: '实战', + order: 2, + }, +] + +const enOverview: DocsPage[] = ([ + ['docs/architecture.md', 'en/index.md', 'Architecture'], + ['docs/cordis-primer.md', 'en/cordis-primer.md', 'Cordis primer'], + ['docs/capability-seams.md', 'en/capability-seams.md', 'Capability services'], + ['docs/agent-lifecycle.md', 'en/agent-lifecycle.md', 'Agent lifecycle'], + ['docs/tool-execution-pipeline.md', 'en/tool-execution-pipeline.md', 'Tool execution'], +] as const).map(([source, route, label], order) => ({ + source, + route, + label, + sidebar: 'en-docs', + section: 'Concepts', + order, +})) + +const enCatalogs: DocsPage[] = ([ + ['docs/config-catalog.md', 'en/config-catalog.md', 'Plugin configuration'], + ['docs/tool-catalog.md', 'en/tool-catalog.md', 'Tool schemas'], + ['docs/cordis-catalog/services.md', 'en/cordis-catalog/services.md', 'Services'], + ['docs/cordis-catalog/events.md', 'en/cordis-catalog/events.md', 'Events'], + ['docs/persistence-catalog.md', 'en/persistence-catalog.md', 'Persistence events'], +] as const).map(([source, route, label], order) => ({ + source, + route, + label, + sidebar: 'en-docs', + section: 'Generated reference', + order, +})) + +const corePages = [ + ['core.md', 'Core data structures'], + ['session.md', 'Sessions'], + ['tools.md', 'Tools'], + ['llm-streaming.md', 'LLM streaming'], + ['bash.md', 'Bash execution'], + ['filesystem.md', 'Filesystem'], + ['code-runtime.md', 'Code runtime'], + ['compaction.md', 'Compaction'], + ['subagent.md', 'Subagents'], + ['workflow.md', 'Workflows'], + ['skills.md', 'Skills'], + ['approval.md', 'Approvals'], + ['user-interaction.md', 'User interaction'], + ['sandbox.md', 'Sandboxing'], + ['web.md', 'Web access'], + ['persistence.md', 'Session persistence'], +] as const + +const enCore: DocsPage[] = corePages.map(([file, label], order) => ({ + source: `docs/core-data-structures/${file}`, + route: `en/core-data-structures/${file}`, + label, + sidebar: 'en-docs', + section: 'Data structures', + order, + ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), +})) + +const enCookbook: DocsPage[] = ([ + ['adding-a-package.md', 'Adding a package'], + ['adding-a-tool.md', 'Adding a tool'], + ['adding-an-llm-adapter.md', 'Adding an LLM adapter'], + ['extension-cookbook.md', 'Extension patterns'], +] as const).map(([file, label], order) => ({ + source: `docs/cookbook/${file}`, + route: `en/cookbook/${file}`, + label, + sidebar: 'en-docs', + section: 'Cookbook', + order, +})) + +/** Every canonical page published by the documentation website. */ +export const docsPages: DocsPage[] = [ + ...zhGuide, + ...zhDevelop, + ...enOverview, + ...enCatalogs, + ...enCore, + ...enCookbook, +] diff --git a/website/package.json b/website/package.json index 33c32fae4c..a2279418bb 100644 --- a/website/package.json +++ b/website/package.json @@ -4,12 +4,19 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "vitepress dev . --port 5173 --open", + "dev": "vitepress dev . --host 127.0.0.1 --port 5173", "build": "vitepress build .", - "preview": "vitepress preview ." + "preview": "vitepress preview . --host 127.0.0.1 --port 4173" }, "devDependencies": { - "vitepress": "^1.6.3", - "vue": "^3.5.13" + "@braintree/sanitize-url": "7.1.2", + "cytoscape": "3.34.0", + "cytoscape-cose-bilkent": "4.1.0", + "dayjs": "1.11.21", + "debug": "4.4.3", + "mermaid": "11.16.0", + "vite": "^5.4.14", + "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17" } } diff --git a/website/zh-CN/api/cordis/context.md b/website/zh-CN/api/cordis/context.md deleted file mode 100644 index a18f275dad..0000000000 --- a/website/zh-CN/api/cordis/context.md +++ /dev/null @@ -1,85 +0,0 @@ -# Context - -上下文对象是 Cordis 的核心。所有服务、方法、属性都通过 `ctx` 访问。 - -## 服务与混入 - -Context 基于组合式 API 设计,大部分属性和方法挂载在服务上。以下是核心 API: - -- [`ctx.on`](./events#ctx-on) — 注册事件监听器 -- [`ctx.emit`](./events#ctx-emit) — 触发事件 -- [`ctx.bail`](./events#ctx-bail) — 短路事件 -- [`ctx.serial`](./events#ctx-serial) — 顺序异步事件 -- [`ctx.waterfall`](./events#ctx-waterfall) — 管道事件 -- [`ctx.effect`](./fiber#fiber-effect) — 注册可逆效果 -- [`ctx.plugin`](./registry#ctx-plugin) — 加载子插件 -- [`ctx.inject`](./registry#ctx-inject) — 获取依赖的插件 -- [`ctx.get`](#ctx-get) — 获取服务 -- [`ctx.set`](#ctx-set) — 设置服务 -- [`ctx.provide`](#ctx-provide) — 声明服务 - -## 实例属性 - -### ctx.fiber - -- **类型:** [`Fiber`](./fiber) - -当前上下文的作用域对象。 - -## 实例方法 - -### ctx.extend(meta) - -- **meta:** `object` -- **返回值:** `Context` - -构造一个以当前上下文为原型的新上下文实例。 - -### ctx.intercept(name, config) - -- **name:** `string` 服务名称 -- **config:** `object` 配置拦截 -- **返回值:** `Context` - -为指定服务添加一层配置拦截,返回新的上下文实例。 - -### ctx.isolate(name, label?) - -- **name:** `string` 服务名称 -- **label:** `symbol` 隔离域符号(可选) -- **返回值:** `Context` - -创建一个针对指定服务的隔离域,返回新的上下文实例。隔离域中的同名服务互不影响。 - -### ctx.get(name) - -- **name:** `string` 服务名称 -- **返回值:** `Service | undefined` - -获取指定名称的服务实例。 - -### ctx.set(name, value) - -- **name:** `string` 服务名称 -- **value:** `any` 服务值 - -设置指定名称的服务。 - -### ctx.provide(name, value?, options?) - -- **name:** `string` 服务名称 -- **value:** `any` 初始值(可选) -- **options:** `object` -- **返回值:** `void` - -声明一个服务。声明后其他插件可以通过 `inject` 依赖它。 - -## 静态属性 - -### Context.events - -内置事件服务的 symbol key。 - -### Context.current - -当前活跃的 Context 实例(在异步链中通过 AsyncLocalStorage 追踪)。 diff --git a/website/zh-CN/api/cordis/events.md b/website/zh-CN/api/cordis/events.md deleted file mode 100644 index dbc03a87bc..0000000000 --- a/website/zh-CN/api/cordis/events.md +++ /dev/null @@ -1,120 +0,0 @@ -# Events - -`ctx.events` 是内置服务,提供事件系统相关的全部 API。 - -## 实例方法 - -### ctx.on(event, listener, options?) {#ctx-on} - -- **event:** `string` 事件名称 -- **listener:** `Function` 事件监听器 -- **options:** `object` - - **prepend:** `boolean` 是否注册为前置(默认 `false`) - - **global:** `boolean` 是否注册为全局(默认 `false`) -- **返回值:** `() => void` 取消注册函数 - -注册一个事件监听器。返回的函数可用于手动取消注册,但通常不需要——插件卸载时会自动清理。 - -```typescript -ctx.on('agent/turn-end', (data) => { - console.log('turn ended:', data) -}) -``` - -### ctx.emit(thisArg?, event, ...args) {#ctx-emit} - -- **thisArg:** `any` 监听器的 `this` 参数(可选) -- **event:** `string` 事件名称 -- **args:** `any[]` 事件参数 -- **返回值:** `void` - -同步触发所有匹配的监听器(并行,不等待异步完成)。 - -### ctx.parallel(thisArg?, event, ...args) - -- 签名同 `emit` -- **返回值:** `Promise` - -异步触发所有匹配的监听器(并行等待)。 - -### ctx.bail(thisArg?, event, ...args) {#ctx-bail} - -- **返回值:** `any` - -同步依次触发监听器。第一个返回非 `undefined`/`null`/`false` 值的监听器停止链并返回该值。 - -### ctx.serial(thisArg?, event, ...args) {#ctx-serial} - -- **返回值:** `Promise` - -异步依次触发监听器。语义同 `bail` 的异步版本。 - -### ctx.waterfall(thisArg?, event, ...args) {#ctx-waterfall} - -- **返回值:** `Promise` - -管道模式:每个监听器接收前一个的输出。监听器内部必须调用 `next()` 才会传递给下一个。 - -```typescript -// 注册 -ctx.on('llm/pre-request', async (messages, next) => { - messages.push(extraMsg) - return next(messages) // 必须调用 -}) - -// 触发 -const result = await ctx.waterfall('llm/pre-request', initialMessages) -``` - -::: warning -不调用 `next()` 即为否决 (veto)——管道终止。这是设计行为,用于拦截/网关。 -::: - -## Harness 内置事件 - -### agent/pre-step - -- **触发模式:** serial -- **参数:** `{ agentId, turnIndex }` - -Agent 执行一步之前触发。 - -### agent/post-step - -- **触发模式:** emit -- **参数:** `{ agentId, turnIndex, blocks }` - -Agent 执行一步之后触发。 - -### tool/call - -- **触发模式:** emit -- **参数:** `{ name, args, callId }` - -Tool 被模型调用时触发。 - -### tool/result - -- **触发模式:** emit -- **参数:** `{ name, result, callId }` - -Tool 返回结果时触发。 - -### session/event - -- **触发模式:** emit -- **参数:** `SessionEvent` - -会话事件被记录时触发。 - -### compact/start - -- **触发模式:** emit - -上下文压缩开始。 - -### compact/end - -- **触发模式:** emit - -上下文压缩结束。 diff --git a/website/zh-CN/api/cordis/fiber.md b/website/zh-CN/api/cordis/fiber.md deleted file mode 100644 index ffb8f23bb5..0000000000 --- a/website/zh-CN/api/cordis/fiber.md +++ /dev/null @@ -1,108 +0,0 @@ -# Fiber - -Fiber(作用域)是插件实例的运行时容器,管理其生命周期和效果。 - -## 状态机 - -``` -PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED - ↘ FAILED -``` - -| 状态 | 数值 | 含义 | -|------|------|------| -| PENDING | 0 | 依赖未就绪,等待中 | -| LOADING | 1 | 正在执行 `apply` | -| ACTIVE | 2 | 运行中 | -| FAILED | 3 | `apply` 抛出异常 | -| UNLOADING | 4 | 正在撤销效果 | -| DISPOSED | 5 | 已完全卸载 | - -## 实例属性 - -### fiber.uid - -- **类型:** `number` - -Fiber 的唯一标识符。 - -### fiber.status - -- **类型:** `number` - -当前状态(见状态机)。 - -### fiber.config - -- **类型:** `object` - -传递给插件的配置对象。 - -### fiber.error - -- **类型:** `Error | undefined` - -如果状态是 FAILED,包含导致失败的异常。 - -## 实例方法 - -### fiber.effect(callback) {#fiber-effect} - -- **callback:** `() => (() => void) | void` -- **返回值:** `() => void` - -注册一个效果。`callback` 在 Fiber 激活时执行;如果返回函数,该函数在 Fiber dispose 时执行。 - -```typescript -ctx.effect(() => { - const timer = setInterval(tick, 1000) - return () => clearInterval(timer) -}) -``` - -等价地可以通过 `ctx.effect()` 调用(ctx 代理到当前 fiber)。 - -### fiber.dispose() - -- **返回值:** `Promise` - -手动 dispose 该 Fiber。按注册逆序撤销所有效果,递归 dispose 所有子 Fiber。 - -```typescript -const child = ctx.plugin(somePlugin) -// 之后: -await child.dispose() -``` - -### fiber.update(config) - -- **config:** `object` 新配置 -- **返回值:** `void` - -热更新配置。如果新旧配置不同,触发 dispose + 重新 apply。 - -### fiber.restart() - -- **返回值:** `void` - -强制重启:dispose 后重新加载。 - -### fiber.then(resolve, reject?) - -- **返回值:** `Promise` - -使 Fiber 可以被 `await`:等到状态进入 ACTIVE 或 FAILED。 - -```typescript -const fiber = ctx.plugin(myPlugin) -await fiber // 等待插件加载完成 -``` - -## 访问当前 Fiber - -```typescript -export function apply(ctx: Context) { - const fiber = ctx.fiber // 当前插件的 Fiber - console.log(fiber.status) // 1 (LOADING, 因为正在 apply 中) -} -``` diff --git a/website/zh-CN/api/cordis/registry.md b/website/zh-CN/api/cordis/registry.md deleted file mode 100644 index e0f66d8ed7..0000000000 --- a/website/zh-CN/api/cordis/registry.md +++ /dev/null @@ -1,87 +0,0 @@ -# Registry - -插件注册表,管理插件的加载和依赖解析。 - -## 实例方法 - -### ctx.plugin(plugin, config?) {#ctx-plugin} - -- **plugin:** `Plugin` 插件(函数、对象或类) -- **config:** `object` 传递给插件的配置(可选) -- **返回值:** `Fiber` - -加载一个子插件,返回其 Fiber。子 Fiber 的生命周期绑定到父上下文。 - -```typescript -// 函数插件 -ctx.plugin(myPlugin, { key: 'value' }) - -// 类插件 -ctx.plugin(MyService) - -// 返回的 Fiber 可以 await 或 dispose -const fiber = ctx.plugin(myPlugin) -await fiber -``` - -### ctx.inject(names, callback) {#ctx-inject} - -- **names:** `string[]` 服务名列表 -- **callback:** `(ctx: Context) => void` -- **返回值:** `() => void` - -等待指定服务全部就绪后执行 callback。如果服务消失,callback 的效果会自动撤销;服务恢复后重新执行。 - -```typescript -ctx.inject(['tools', 'llm'], (ctx) => { - // tools 和 llm 都就绪了 - ctx.tools.register(/* ... */) -}) -``` - -这是 `export const inject = [...]` 声明的底层 API。大多数情况下直接使用声明式写法即可。 - -## 插件形态 - -`ctx.plugin()` 接受三种插件形态: - -### 函数插件 - -```typescript -function myPlugin(ctx: Context, config?: Config) { - // ... -} -myPlugin.name = 'my-plugin' -myPlugin.inject = ['tools'] -``` - -### 对象插件 - -```typescript -const myPlugin = { - name: 'my-plugin', - inject: ['tools'], - apply(ctx: Context, config?: Config) { - // ... - }, -} -``` - -### 类插件(Service) - -```typescript -class MyService extends Service { - static inject = ['tools'] - constructor(ctx: Context) { - super(ctx, 'myService') - } -} -``` - -## 插件元信息 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | 插件名称(日志用) | -| `inject` | `string[] \| { required?: string[], optional?: string[] }` | 依赖声明 | -| `Config` | `Schema \| object` | 配置 schema 或默认值 | diff --git a/website/zh-CN/api/cordis/service.md b/website/zh-CN/api/cordis/service.md deleted file mode 100644 index a57a00c461..0000000000 --- a/website/zh-CN/api/cordis/service.md +++ /dev/null @@ -1,97 +0,0 @@ -# Service - -Service 基类,用于创建对外暴露能力的插件。 - -## 基本用法 - -```typescript -import { Service, type Context } from 'cordis' - -declare module 'cordis' { - interface Context { - myService: MyService - } -} - -export default class MyService extends Service { - constructor(ctx: Context) { - super(ctx, 'myService') - } - - // 公开方法 - doSomething() { - // ... - } -} -``` - -加载后,其他插件可通过 `ctx.myService` 访问。 - -## 构造函数 - -### new Service(ctx, name) - -- **ctx:** `Context` 上下文 -- **name:** `string` 服务名(注册到 `ctx[name]`) - -## 实例属性 - -### service.ctx - -- **类型:** `Context` - -该服务绑定的上下文。 - -### service\[Service.tracker\] - -- **类型:** `object` - -服务追踪信息(名称、绑定状态等)。 - -## 生命周期 - -Service 子类可以覆写以下方法: - -### start() - -服务激活时调用。在这里初始化资源。 - -### stop() - -服务停用时调用。在这里释放资源。 - -## 静态属性 - -### Service.inject - -- **类型:** `string[] | { required?: string[], optional?: string[] }` - -声明本服务依赖的其他服务。 - -## 与 inject 的关系 - -当一个 Service 被加载: -1. 框架为该服务名创建声明 (`ctx.provide`) -2. 实例赋值到 `ctx[name]` -3. 依赖该服务的所有 Fiber 从 PENDING 转为 LOADING - -当 Service 被卸载: -1. `ctx[name]` 被置为 `undefined` -2. 依赖它的 Fiber 被 dispose -3. 当新的 provider 出现时,dependant Fiber 重新加载 - -## 示例:Harness 中的 Service - -```typescript -// dsh-tools 的 ToolRegistry 就是一个 Service -export class ToolRegistry extends Service { - constructor(ctx: Context) { - super(ctx, 'tools') - } - - register(tool: ToolDefinition): () => void { - // ...注册逻辑 - return dispose - } -} -``` diff --git a/website/zh-CN/api/harness/agent.md b/website/zh-CN/api/harness/agent.md deleted file mode 100644 index bf46c7e4e1..0000000000 --- a/website/zh-CN/api/harness/agent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Agent (dsh-agent) - -Agent 实例管理和生命周期。 - -**包名:** `@deepseek-ai/dsh-agent` -**服务名:** `ctx.agents` - -## Agent Service - -### ctx.agents.create(options) - -- **options:** `AgentOptions` -- **返回值:** `Agent` - -创建一个新的 Agent 实例。 - -### ctx.agents.get(id) - -- **id:** `AgentId` -- **返回值:** `Agent | undefined` - -获取指定 ID 的 Agent 实例。 - -## AgentOptions - -```typescript -interface AgentOptions { - /** Agent ID(branded) */ - id?: AgentId - /** 使用的模型名 */ - model: string - /** 系统提示词(支持 {{model}} 变量) */ - persona?: string - /** 关联的 session */ - session?: Session -} -``` - -## Agent 实例 - -### agent.id - -- **类型:** `AgentId` - -Agent 的唯一标识符(branded string)。 - -### agent.model - -- **类型:** `string` - -Agent 使用的模型名。 - -### agent.step(input) - -- **input:** `ContentBlock[]` -- **返回值:** `Promise` - -执行一步:将输入发送给模型,获取响应,执行 tool calls。这是 agent-loop 内部使用的核心方法。 - -## Agent Loop - -Agent 的执行循环由 `dsh-agent-loop` 管理。它: - -1. 组装 system prompt + 历史消息 + 当前输入 -2. 调用 LLM(通过 `ctx.llm`) -3. 解析响应中的 tool calls -4. 执行 tools -5. 将 tool results 追加到 session -6. 如果 finish reason 是 `tool-calls`,回到步骤 2 - -### 扩展点 - -- `agent/pre-step` 事件 — 在每一步 LLM 调用前触发 -- `agent/post-step` 事件 — 在每一步完成后触发 -- `llm/pre-request` waterfall — 可修改发送给模型的消息 - -## AgentId - -Opaque branded string: - -```typescript -import { AgentId } from '@deepseek-ai/dsh-agent' - -const id = AgentId('main') -``` diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md deleted file mode 100644 index 8e8d8d3068..0000000000 --- a/website/zh-CN/api/harness/bash.md +++ /dev/null @@ -1,81 +0,0 @@ -# Bash (dsh-bash) - -Bash 命令执行接口。 - -**接口包:** `@deepseek-ai/dsh-bash` -**实现:** `@deepseek-ai/dsh-bash-local` -**消费者:** `@deepseek-ai/dsh-tool-bash`(内置于 agent-core) - -## Bash Service - -### ctx.bash.execute(request) - -- **request:** `BashRequest` -- **返回值:** `Promise` - -执行一个 bash 命令。 - -## BashRequest - -```typescript -interface BashRequest { - /** 要执行的命令 */ - command: string - /** 工作目录 */ - workdir?: string - /** 超时时间 (ms) */ - timeoutMs?: number -} -``` - -## BashResult - -```typescript -interface BashResult { - /** 退出码 */ - exitCode: number - /** stdout 输出 */ - stdout: string - /** stderr 输出 */ - stderr: string - /** 是否超时 */ - timedOut: boolean -} -``` - -## 配置 (dsh-bash-local) - -```typescript -interface Config { - /** 命令超时时间,默认 120000 (2 分钟) */ - timeoutMs: number -} -``` - -在 `cordis.yml` 中: - -```yaml -- name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 -``` - -## 模型可用的 Tools - -`dsh-tool-bash` 向模型暴露以下 tools(由 `agent-core` 捆绑): - -| Tool | 说明 | -|------|------| -| `bash` | 执行命令(同步,等待完成) | -| `bash_output` | 获取后台命令的输出 | -| `bash_kill` | 终止后台命令 | - -## 设计模式 - -Bash 是 Harness 的"能力三件套"典型案例: - -- `dsh-bash`(接口):定义 `ctx.bash` 和 `BashRequest`/`BashResult` 类型 -- `dsh-bash-local`(实现):通过 `child_process.spawn` 在本地执行 -- `dsh-tool-bash`(消费者):将能力包装为模型可调用的 tool - -换一个沙箱执行器只需替换 `dsh-bash-local`,接口和 tool 不变。 diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md deleted file mode 100644 index 4e336ff962..0000000000 --- a/website/zh-CN/api/harness/fs.md +++ /dev/null @@ -1,78 +0,0 @@ -# Filesystem (dsh-fs) - -文件系统操作接口。 - -**接口包:** `@deepseek-ai/dsh-fs` -**实现:** `@deepseek-ai/dsh-fs-local` + `@deepseek-ai/dsh-fs-policy` -**消费者:** `@deepseek-ai/dsh-tool-fs` - -## FS Service - -### ctx.fs.read(path, options?) - -- **path:** `string` -- **options:** `{ offset?: number; limit?: number }` -- **返回值:** `Promise` - -读取文件内容。 - -### ctx.fs.write(path, content) - -- **path:** `string` -- **content:** `string` -- **返回值:** `Promise` - -写入文件(覆盖)。 - -### ctx.fs.edit(path, edits) - -- **path:** `string` -- **edits:** `Edit[]` -- **返回值:** `Promise` - -对文件执行精确的字符串替换编辑。 - -### ctx.fs.stat(path) - -- **path:** `string` -- **返回值:** `Promise` - -获取文件/目录信息。 - -## 配置 (dsh-fs-local) - -```typescript -interface Config { - /** 工作目录(相对路径的基准) */ - cwd: string -} -``` - -## 策略门 (dsh-fs-policy) - -`dsh-fs-policy` 是一个可选的中间层插件,实现 read-before-write/edit 策略——模型必须先读取文件才能写入或编辑。这防止模型盲目覆盖文件。 - -在 `cordis.yml` 中,它位于 `fs-local` 和 `tool-fs` 之间: - -```yaml -- name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() -- name: '@deepseek-ai/dsh-fs-policy' -- name: '@deepseek-ai/dsh-tool-fs' -``` - -## 模型可用的 Tools - -| Tool | 说明 | -|------|------| -| `read` | 读取文件内容(支持 offset/limit) | -| `write` | 写入文件(需要先 read) | -| `edit` | 精确字符串替换(需要先 read) | - -## 三件套结构 - -- `dsh-fs`:接口定义 -- `dsh-fs-local`:本地文件系统实现 -- `dsh-fs-policy`:策略门(read-before-write 检查) -- `dsh-tool-fs`:模型 tool 层 diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md deleted file mode 100644 index 82a4d8e225..0000000000 --- a/website/zh-CN/api/harness/llm.md +++ /dev/null @@ -1,124 +0,0 @@ -# LLM (dsh-llm) - -LLM 服务接口和适配器注册。 - -**包名:** `@deepseek-ai/dsh-llm` -**服务名:** `ctx.llm` - -## LLM Service - -### ctx.llm.registerAdapter(models, adapter) - -- **models:** `string[]` 该适配器支持的模型名列表 -- **adapter:** `LlmAdapter` 适配器实例 -- **返回值:** `() => void` disposer - -注册一个 LLM 适配器。当请求中指定的模型名在 `models` 列表中时,路由到该适配器。 - -```typescript -ctx.llm.registerAdapter(['deepseek-v4-flash', 'deepseek-v4-pro'], adapter) -``` - -## LlmAdapter - -适配器基类。子类必须实现 `stream()` 方法。 - -### stream(options) - -- **options:** `GenerateOptions` -- **返回值:** `AsyncIterable` - -将统一请求格式转换为具体 API 的流式调用。 - -## GenerateOptions - -```typescript -interface GenerateOptions { - model: string - messages: Message[] - tools?: ToolSpec[] - system?: string - maxTokens?: number - temperature?: number -} -``` - -| 字段 | 说明 | -|------|------| -| `model` | 请求的模型名 | -| `messages` | 对话历史 | -| `tools` | 当前可用的 tool 列表(JSON Schema 格式) | -| `system` | 系统提示词 | -| `maxTokens` | 最大输出 token | -| `temperature` | 采样温度 | - -## StreamChunk - -流式响应的增量 chunk 类型: - -```typescript -type StreamChunk = - | { type: 'block-start'; index: number; blockType: 'text' | 'tool-call' } - | { type: 'text-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: CallId; name: string; argumentsDelta: string } - | { type: 'block-end'; index: number; block: ContentBlock } - | { type: 'usage'; usage: TokenUsage } - | { type: 'finish'; reason: FinishReason } -``` - -### 协议规则 - -1. 每个内容块以 `block-start` 开始,以 `block-end` 结束 -2. `index` 从 0 递增 -3. `text-delta` 只在 `blockType: 'text'` 的块中 -4. `tool-call-delta` 只在 `blockType: 'tool-call'` 的块中 -5. `usage` 在 `finish` 之前 -6. `finish` 必须是最后一个 chunk - -## CallId - -Tool call 的 opaque branded ID: - -```typescript -import { CallId } from '@deepseek-ai/dsh-llm' - -const id = CallId('call-abc123') -``` - -## TokenUsage - -```typescript -interface TokenUsage { - inputTokens: number - outputTokens: number -} -``` - -## FinishReason - -```typescript -type FinishReason = - | { kind: 'stop' } - | { kind: 'tool-calls' } - | { kind: 'max-tokens' } -``` - -## Message - -对话消息类型: - -```typescript -interface Message { - role: 'user' | 'assistant' - content: ContentBlock[] -} -``` - -## ContentBlock - -```typescript -type ContentBlock = - | { type: 'text'; text: string } - | { type: 'tool-call'; id: CallId; name: string; arguments: string } - | { type: 'tool-result'; callId: CallId; content: ContentBlock[]; isError?: boolean } -``` diff --git a/website/zh-CN/api/harness/session.md b/website/zh-CN/api/harness/session.md deleted file mode 100644 index 5ff5b0d97b..0000000000 --- a/website/zh-CN/api/harness/session.md +++ /dev/null @@ -1,56 +0,0 @@ -# Session (dsh-session) - -会话事件流管理。 - -**包名:** `@deepseek-ai/dsh-session` -**服务名:** `ctx.session` - -## 概述 - -Session 是 Agent 的对话状态容器。所有模型可见的内容都必须经过 session 事件流记录——这是"model-visible = logged"原则的实现。 - -## SessionSurface - -会话的外部接口,用于查询当前状态。 - -### surface.messages - -- **类型:** `Message[]` - -当前会话的完整消息列表(经过 compaction 处理后的视图)。 - -### surface.events - -- **类型:** `SessionEvent[]` - -原始事件流。 - -## SessionEvent - -会话中所有变更以事件形式记录: - -```typescript -type SessionEvent = - | { type: 'user/message'; content: ContentBlock[] } - | { type: 'assistant/message'; content: ContentBlock[] } - | { type: 'tool/call'; name: string; args: unknown; callId: CallId } - | { type: 'tool/result'; callId: CallId; content: ContentBlock[]; isError?: boolean } - | { type: 'compact/start'; range: [number, number] } - | { type: 'compact/end'; summary: string } - | { type: 'todo/write'; items: TodoItem[] } - // ... 更多事件类型 -``` - -## 设计原则 - -### Model-visible = Logged - -任何到达模型请求的内容都必须能从 session log 重建。如果你要引入新的模型可见输入,必须先定义对应的 session event。 - -### 事件是 append-only - -Session 事件流是只追加的。修改历史(如 compaction)通过新事件(compact/start + compact/end)表达,而不是修改旧事件。 - -### 持久化 - -Session 事件流可以通过 `dsh-session-persistence` 持久化到磁盘(JSONL 或 SQLite),实现跨进程恢复。 diff --git a/website/zh-CN/api/harness/subagent.md b/website/zh-CN/api/harness/subagent.md deleted file mode 100644 index 97ad7b5c87..0000000000 --- a/website/zh-CN/api/harness/subagent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Subagent (dsh-subagent) - -子代理委派接口。 - -**接口包:** `@deepseek-ai/dsh-subagent` -**实现:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` -**消费者:** `@deepseek-ai/dsh-tool-subagent` - -## Subagent Service - -### ctx.subagent.run(request) - -- **request:** `SubagentRequest` -- **返回值:** `Promise` - -委派一个任务给子代理执行。 - -## SubagentRequest - -```typescript -interface SubagentRequest { - /** 使用的 provider 名称 */ - provider: string - /** 委派给子代理的提示 */ - prompt: string - /** 子代理使用的模型(可选,默认继承父) */ - model?: string -} -``` - -## SubagentResult - -```typescript -interface SubagentResult { - /** 子代理的最终回复 */ - response: string -} -``` - -## Provider 模式 - -Subagent 支持多种"后端"(provider),通过配置选择: - -### spawn - -创建一个全新的子代理实例,没有父级的对话历史: - -```yaml -- name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn -``` - -### fork - -创建一个携带父级已完成 turn 前缀的子代理,子代理"知道"父级的对话上下文: - -```yaml -- name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork -``` - -## 模型可用的 Tools - -通过 `dsh-tool-subagent` 暴露。可以加载多次,每次绑定不同 provider: - -```yaml -# 暴露为 "subagent" tool,使用 spawn 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -# 暴露为 "subagent_fork" tool,使用 fork 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork -``` - -## 使用场景 - -- **spawn** — 独立子任务(如"搜索这个问题"),子代理不需要知道父级上下文 -- **fork** — 需要上下文的子任务(如"基于我们刚才讨论的,去实现这个"),子代理继承父级的对话前缀 diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md deleted file mode 100644 index d2011ad85e..0000000000 --- a/website/zh-CN/api/harness/tools.md +++ /dev/null @@ -1,122 +0,0 @@ -# Tools (dsh-tools) - -Tool 注册表和 `defineTool` DSL。 - -**包名:** `@deepseek-ai/dsh-tools` -**服务名:** `ctx.tools` - -## ToolRegistry - -### ctx.tools.register(tool) - -- **tool:** `ToolDefinition` -- **返回值:** `() => void` disposer - -注册一个 tool。返回的 disposer 可手动撤销注册(通常不需要,插件卸载时自动撤销)。 - -## defineTool\(options) - -类型安全的 tool 定义辅助函数。 - -```typescript -import { defineTool } from '@deepseek-ai/dsh-tools' - -const tool = defineTool({ - name: 'read_file', - description: 'Read a file from disk.', - parameters: { - path: { type: 'string', required: true, description: 'Absolute file path' }, - offset: { type: 'number' }, - limit: { type: 'number', description: 'Max lines to read' }, - }, - async execute(args) { - // args: { path: string; offset?: number; limit?: number } - }, -}) -``` - -### DefineToolOptions\ - -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | Tool 名称(全局唯一) | -| `description` | `string` | 发送给模型的描述 | -| `parameters` | `SchemaSpec` | 参数 schema(见下文) | -| `execute` | `(args: InferArgs, exec: ToolExecution) => Promise` | 执行函数 | -| `presentCall?` | `(args: InferArgs) => ToolCallView \| undefined` | UI 展示(纯函数) | -| `presentResult?` | `(args: InferArgs, result: ToolResult) => ToolResultView \| undefined` | 结果 UI 展示(纯函数) | - -## SchemaSpec - -参数 schema DSL。每个属性是一个 `SchemaProp`: - -```typescript -interface SchemaProp { - type: 'string' | 'number' | 'boolean' | 'object' | 'array' - required?: true - description?: string - enum?: string[] - properties?: SchemaSpec // type: 'object' 时 - items?: SchemaProp // type: 'array' 时 -} -``` - -### 类型推导 (InferArgs) - -`InferArgs` 自动从 `SchemaSpec` 推导 TypeScript 类型: - -- `required: true` → 必填字段 -- 无 `required` → 可选字段(`?`) -- `type: 'object'` + `properties` → 递归推导嵌套对象 -- `type: 'array'` + `items` → 推导为数组 - -## ToolDefinition - -运行时 tool 定义(`defineTool` 的返回值): - -```typescript -interface ToolDefinition { - name: string - description: string - parameters: Record // JSON Schema - execute(args: unknown, exec: ToolExecution): Promise - presentCall?(args: unknown): ToolCallView | undefined - presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined -} -``` - -## ToolExecuteReturn - -```typescript -type ToolExecuteReturn = - | ContentBlock[] // 仅内容 - | { content: ContentBlock[]; meta?: unknown } // 内容 + 元信息 -``` - -## ToolArgsError - -当模型生成的参数不匹配 schema 时抛出: - -```typescript -class ToolArgsError extends HarnessError { - code: 'INVALID_ARGS' - violations: string[] -} -``` - -框架自动捕获并转换为 `isError` 结果返回给模型。 - -## validateArgs(spec, args) - -- **spec:** `SchemaSpec` -- **args:** `unknown` -- **返回值:** `string[]` 违规信息列表(空 = 合法) - -手动校验参数。`defineTool` 内部使用,通常不需要直接调用。 - -## schemaSpecToJsonSchema(spec) - -- **spec:** `SchemaSpec` -- **返回值:** `JsonSchemaObject` - -将 SchemaSpec 转换为标准 JSON Schema。用于发送给模型的 wire format。 diff --git a/website/zh-CN/api/index.md b/website/zh-CN/api/index.md deleted file mode 100644 index 371cd1e622..0000000000 --- a/website/zh-CN/api/index.md +++ /dev/null @@ -1,25 +0,0 @@ -# API 参考 - -本节提供 DeepSeek Harness 的完整 API 参考文档,分为两部分: - -## 框架 API - -Cordis 微内核提供的基础能力,所有插件开发都建立在这些 API 之上: - -- [Context](./cordis/context) — 上下文对象,所有服务和方法的入口 -- [Events](./cordis/events) — 事件系统 API(emit / on / bail / serial / waterfall) -- [Fiber](./cordis/fiber) — 作用域生命周期(状态机、effect、dispose) -- [Registry](./cordis/registry) — 插件注册(plugin / inject) -- [Service](./cordis/service) — 服务基类 - -## Harness API - -DeepSeek Harness SDK 提供的扩展 API,用于构建 Agent 能力: - -- [Tools (dsh-tools)](./harness/tools) — Tool 注册、defineTool DSL、Schema 类型系统 -- [LLM (dsh-llm)](./harness/llm) — LLM 服务、适配器注册、StreamChunk 协议 -- [Session (dsh-session)](./harness/session) — 会话事件流、消息类型 -- [Agent (dsh-agent)](./harness/agent) — Agent 实例管理、生命周期 -- [Bash (dsh-bash)](./harness/bash) — Bash 执行接口 -- [Filesystem (dsh-fs)](./harness/fs) — 文件系统接口 -- [Subagent (dsh-subagent)](./harness/subagent) — 子代理委派接口 diff --git a/website/zh-CN/design/composability.md b/website/zh-CN/design/composability.md deleted file mode 100644 index 8370d8e136..0000000000 --- a/website/zh-CN/design/composability.md +++ /dev/null @@ -1,72 +0,0 @@ -# 可组合性与插件系统 - -## 组合 - -编程的本质就是组合。将小的构建块拼装为更大的系统,再将大系统作为块继续拼装——这是从函数到模块到微服务一脉相承的思想。 - -组合可以分为两种: - -- **静态组合**:编译期确定的组合,例如函数调用、模块导入。 -- **动态组合**:运行时确定的组合,例如热更新、插件加载/卸载。 - -静态组合是逻辑的组合;动态组合为可组合性引入了时间和空间两个新维度。 - -## 三种可组合性 - -| 维度 | 定义 | 对应问题 | -|------|------|----------| -| **逻辑可组合性** (Logical) | 功能能否被任意拆分和组装 | 接口设计是否正交 | -| **时间可组合性** (Temporal) | 能否灵活、安全地控制组合的运行时序 | 能否热加载/卸载而不泄漏 | -| **空间可组合性** (Spatial) | 能否灵活、安全地管理组合的依赖关系 | 依赖缺失时行为是否确定 | - -一门编程语言或应用框架越多地使用组合范式,就称它的可组合性越好。 - -## 传统插件系统的问题 - -插件系统是动态组合的典型形式。浏览器扩展、IDE 插件、操作系统驱动,都是其实例。然而大多数插件系统并不可靠。 - -### 不可逆的插件化 - -以 VSCode 为例: - -- 卸载或更新插件时需要重启整个系统。 -- 无法在运行时追踪和回收副作用,导致内存泄漏和非预期的资源占用。 -- 即便提供了 `deactivate` 钩子,也无法强制开发者正确实现清理逻辑。 - -**根本原因**:未做到时间可组合——系统不知道某个插件产生了哪些副作用、占用了哪些资源。 - -### 不完全的插件化 - -- 无法表达插件间的依赖关系,扩展能力受限。 -- 只有外围功能被下放给插件,核心功能依然通过修改主体代码来实现。 - -**根本原因**:未做到空间可组合——系统缺乏对依赖关系的建模和管理。 - -## Cordis 的解法 - -Cordis 同时解决了上述两个问题: - -1. **可逆作用** (Revertible Effects) 实现时间可组合性——所有注册自动追踪、自动回收。 -2. **响应式余作用** (Reactive Coeffects) 实现空间可组合性——依赖声明驱动加载顺序。 - -两者通过**上下文模型** (Context Model) 统一为单一的编程范式:开发者只需通过 `ctx` 调用框架 API,可逆性和依赖管理由框架保证。 - -## 在 Harness 中的体现 - -DeepSeek Harness 将 Cordis 的可组合性应用到 Agent 开发领域: - -```typescript -// 一个 Harness 插件天然是可逆的 -export const inject = ['tools', 'llm'] // 空间可组合:声明依赖 - -export function apply(ctx: Context) { - // 时间可组合:注册会被自动追踪和回收 - ctx.tools.register(defineTool('my-tool', { - description: '...', - parameters: { /* ... */ }, - async execute(args) { /* ... */ }, - })) -} -``` - -插件卸载时,tool 自动注销、事件监听自动移除——无需手动清理。依赖的服务(如 `llm`)消失时,插件自动挂起;恢复时自动重新加载。 diff --git a/website/zh-CN/design/context-model.md b/website/zh-CN/design/context-model.md deleted file mode 100644 index cc25df88e5..0000000000 --- a/website/zh-CN/design/context-model.md +++ /dev/null @@ -1,129 +0,0 @@ -# 上下文模型 - -上下文 (Context) 是 Cordis 将作用与余作用统一的运行时模型。它提供了一种编程范式,允许开发者无心智负担地编写时间、空间可组合的程序。 - -## 作用上下文 (Effect Context) - -当副作用被记录到全局环境时,$\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)$ 也就变成了一个更大的 $\mathcal{C}$。 - -递归地定义: - -$$ -\begin{matrix} -\mathcal{C}_1=\mathcal{C}_0\times\left(\mathcal{C}_0\to\mathcal{C}_0\right)\\ -\mathcal{C}_2=\mathcal{C}_1\times\left(\mathcal{C}_1\to\mathcal{C}_1\right)\\ -\cdots\\ -\mathcal{C}_{n+1}=\mathcal{C}_n\times\left(\mathcal{C}_n\to\mathcal{C}_n\right)\\ -\end{matrix} -$$ - -每一层 $\mathcal{C}$ 包含上一层的状态,同时记录了上一层的副作用。 - -利用递归类型得到真正的作用上下文: - -$$ -\mathcal{C}=\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right) -$$ - -这就是 Cordis Context 的理论根基:**上下文既是状态容器,又是副作用追踪器。** - -## 上下文的派生 - -当一个插件被加载时,从当前上下文派生出新的上下文实例: - -``` -Root Context -├── Plugin A Context ← 管理 A 的副作用 -│ └── Sub-plugin Context -└── Plugin B Context ← 管理 B 的副作用 -``` - -- 子级上下文管理插件内部的全部副作用 -- 插件整体作为一个副作用被父级上下文收集 -- 父级 dispose 时,子级先被 dispose(保证依赖逆序) - -## 余作用上下文 (Coeffect Context) - -余作用由作用产生: - -- **提供服务**本身是一种作用——它占用了服务命名空间资源 -- 因此服务的提供被记录在作用上下文中 -- 上下文将作用与余作用关联起来,提供了统一的时间、空间可组合性 - -```typescript -// 提供服务 = 一个 effect(占用 ctx.llm 这个 "资源") -class LlmService extends Service { - // 当此插件卸载时,ctx.llm 被回收(effect 的逆操作) - // 所有依赖 llm 的插件因 coeffect 不满足而挂起 -} -``` - -## 基于上下文的开发范式 - -上下文模型提供了两个关键优势: - -### 无感性 (Transparent) - -框架将领域中的所有方法都封装为 effect 版本。开发者只需调用 `ctx` 上的方法,就能自动获得时间/空间可组合性: - -```typescript -export function apply(ctx: Context) { - // 以下每一行都是 effect——卸载时自动逆序回收 - ctx.on('agent/step-result', validateResult) - ctx.tools.register(myTool) - ctx.llm.registerAdapter(['my-model'], adapter) - - // 开发者无需知道"可逆作用"的存在 - // 只需通过 ctx 调用,框架保证一切安全 -} -``` - -### 渐进性 (Incremental) - -可以逐步将现有框架中的 API 替换为可组合版本,无需一次性重写: - -```typescript -// 第一步:用 ctx.effect 包装遗留 API -ctx.effect(() => { - const legacy = legacySystem.register(handler) - return () => legacySystem.unregister(legacy) -}) - -// 第二步:在未来将遗留 API 原生改造为 effect -// 两种方式可以并存 -``` - -## 在 Harness 中的完整图景 - -DeepSeek Harness 的运行时是一个 Context 树: - -``` -Root Context (Cordis 应用) -├── dsh-session (提供 ctx.sessions) -├── dsh-tools (提供 ctx.tools) -├── dsh-llm (提供 ctx.llm) -│ └── deepseek-adapter (注册模型适配器) -├── dsh-agent-loop (提供 ctx.agentLoop) -├── dsh-bash (提供 ctx.bash) -│ └── bash-local (本地执行器实现) -├── dsh-fs (提供 ctx.fs) -│ └── fs-local (本地 FS 实现) -├── dsh-system-prompt (提供 ctx.systemPrompt) -└── Agent Context (由 agents.create() 派生) - ├── Agent 自己注册的 tools - ├── Agent 的 session - └── Subagent Context (进一步派生) -``` - -每个节点都是一个 Context 实例。插件加载/卸载、服务出现/消失、Agent 创建/销毁——这一切都在 Context 树上以统一的语义发生。 - -## 总结 - -| 概念 | 解决的问题 | Cordis 机制 | -|------|-----------|-------------| -| 作用上下文 | 副作用追踪与回收 | `ctx.effect()` / `fiber.dispose()` | -| 上下文派生 | 副作用的层级隔离 | `ctx.plugin()` 创建子 Context | -| 余作用上下文 | 依赖的动态管理 | `inject` 声明 + 服务生命周期 | -| 统一范式 | 开发者无需关心底层机制 | 只需通过 `ctx` 调用 API | - -这就是为什么 Harness 能在保持「一切皆插件」的同时,不给插件开发者增加心智负担——**上下文模型把复杂性封装在了框架内部**。 diff --git a/website/zh-CN/design/effects-coeffects.md b/website/zh-CN/design/effects-coeffects.md deleted file mode 100644 index 01c181315f..0000000000 --- a/website/zh-CN/design/effects-coeffects.md +++ /dev/null @@ -1,69 +0,0 @@ -# 作用与余作用 - -## 作用 (Effects) - -Effects 是程序中对系统状态或外部环境产生影响的操作:I/O、状态修改、资源占用等。 - -学术界对作用有两种主要建模方式: - -### 单子作用 (Monadic Effects) - -- 通过单子 (monad) 将副作用封装为类型安全的计算链。 -- 提供 `return`(纯值注入)和 `bind`(链式组合)两个基本操作。 -- 以纯函数式的方式处理带有副作用的计算。(Moggi 1991, Wadler 1992) -- 代表语言:Haskell (IO Monad)、Rust (Result/Option) - -### 代数作用 (Algebraic Effects) - -- 允许在函数中"抛出"一个 effect,在调用栈的更高层次"捕获"并处理。 -- 类似异常处理,但更通用——处理后可以恢复执行。 -- 代表语言:Koka、Eff、OCaml 5+ (Kiselyov 2018, Kawahara 2020) - -## 余作用 (Coeffects) - -Coeffects 是程序执行时依赖的上下文信息:环境变量、系统资源、外部服务等。 - -- Coeffects 是 effects 的对偶 (dual) 概念,通常通过余单子 (comonad) 建模。(Petricek 2013, 2014; Brünnler 2014) -- 更前沿的理论将带有资源的上下文建模为 **graded algebra**(有序半环加最大元): - - 加法 = 并行组合;0 元 = 无资源 - - 乘法 = 串行组合;1 元 = 单位资源 - - 序 = 资源约束;最大元 = 无限资源 - - (Breuvart 2015, Gaboardi 2016, Dal Lago 2022) - -## 现有理论的不足 - -这些理论主要面向**静态分析**和**短时程序**: - -1. **缺乏运行时追踪**:类型系统能标记副作用的存在,但无法在运行时追踪和回收。对长时运行程序(服务端、Agent),这意味着资源泄漏不可避免。 - -2. **缺乏动态性**:面向编译期分析,无法处理运行时的加载/卸载需求。 - -3. **崩溃而非降级**:类型不满足时直接拒绝编译或运行时崩溃,而长时运行程序更希望安全降级——挂起不满足依赖的部分,而非停止整个系统。 - -## Cordis 的突破 - -Cordis 选择了不同的路径——在运行时层面解决可组合性问题: - -| 现有理论 | Cordis 方案 | -|----------|-------------| -| 类型标记副作用 | 运行时追踪并自动回收副作用 | -| 编译期拒绝 | 运行时挂起/恢复 | -| 面向短时程序 | 面向长时运行程序设计 | - -这由两个互补机制实现: - -- **[可逆作用](./revertible-effects)** — 将副作用形式化为可逆的群操作 -- **[响应式余作用](./reactive-coeffects)** — 将依赖建模为具有生命周期的服务 - -## 在 Agent 开发中的意义 - -对 DeepSeek Harness 而言,作用/余作用模型直接支撑了以下能力: - -| 作用 (Effect) | 余作用 (Coeffect) | -|---------------|-------------------| -| 注册一个 tool | 依赖 tool registry 服务 | -| 注册一个 LLM adapter | 依赖 LLM 服务接口 | -| 监听 session 事件 | 依赖 session 服务存在 | -| 启动子进程 | 依赖 bash executor 实现 | - -每一个 effect 都可逆(tool 可注销、adapter 可移除);每一个 coeffect 都有生命周期(服务消失则依赖者挂起)。这就是 Agent 能被安全热替换的根本原因。 diff --git a/website/zh-CN/design/index.md b/website/zh-CN/design/index.md deleted file mode 100644 index de6ebcf7aa..0000000000 --- a/website/zh-CN/design/index.md +++ /dev/null @@ -1,39 +0,0 @@ -# 系统设计 - -DeepSeek Harness 建立在 Cordis 微内核之上,采用「一切皆插件」的架构。本节阐述这套设计背后的理论基础和设计哲学。 - -## 核心思想 - -Harness 追求三种可组合性的统一: - -| 维度 | 含义 | Cordis 对应机制 | -|------|------|----------------| -| 逻辑可组合性 | 功能能否自由拆分和拼装 | 插件系统、事件系统 | -| 时间可组合性 | 运行时能否安全地加载/卸载功能 | 可逆作用、自动清理 | -| 空间可组合性 | 依赖关系能否被安全地声明和管理 | 服务生命周期、依赖注入 | - -这三种可组合性在上下文模型中统一为单一的编程范式。 - -## 目录 - -- [可组合性与插件系统](./composability) — 组合的本质,以及传统插件系统为什么不可靠 -- [作用与余作用](./effects-coeffects) — Cordis 效果系统的理论模型 -- [可逆作用](./revertible-effects) — 时间可组合性的形式化定义与证明 -- [响应式余作用](./reactive-coeffects) — 空间可组合性的服务语义 -- [上下文模型](./context-model) — Context 如何将作用与余作用统一 - -## 设计如何映射到 Harness - -| 理论概念 | Harness 中的体现 | -|----------|-----------------| -| 可逆作用 | `ctx.tools.register()` 返回 disposer;插件卸载时工具自动注销 | -| 响应式余作用 | `inject: ['llm']` 声明依赖;LLM 适配器不可用时插件自动挂起 | -| 上下文派生 | 子 Agent 拥有独立 Context,继承父级服务但有独立生命周期 | -| Waterfall 事件 | `agent/request` 链式拦截,任一监听器可决定最终请求参数 | -| Capability seam | bash/fs/web 三层拆分:接口 → 实现 → 模型工具 | - -## 进一步阅读 - -- [插件与生命周期](/zh-CN/develop/framework/) — 实践中的 Fiber 状态机 -- [服务与依赖](/zh-CN/develop/framework/service) — 服务声明与注入 -- [能力的三层拆分](/zh-CN/develop/practice/) — Capability seam 模式 diff --git a/website/zh-CN/design/reactive-coeffects.md b/website/zh-CN/design/reactive-coeffects.md deleted file mode 100644 index 45345f934a..0000000000 --- a/website/zh-CN/design/reactive-coeffects.md +++ /dev/null @@ -1,90 +0,0 @@ -# 响应式余作用 - -响应式余作用 (Reactive Coeffects) 是 Cordis 实现**空间可组合性**的核心机制。 - -- 将代码中的资源依赖抽象为服务 (service) 的概念 -- 通过运行时生命周期语义,实现自动、安全、高效的资源管理 - -## 依赖的本质是生命周期 - -传统的依赖注入(如 Angular DI、Spring IoC)解决的是"怎么拿到依赖"的问题,但忽略了一个关键问题:**依赖是有生命周期的**。 - -一个数据库连接池可能重启,一个 API 服务可能下线,一个 LLM adapter 可能被热替换。当依赖消失时,依赖者应当如何表现? - -- 崩溃?——对长时运行程序不可接受。 -- 继续运行?——可能产生不一致状态。 -- **自动挂起,等待恢复?**——Cordis 的选择。 - -## 服务与生命周期 - -Cordis 将程序中的资源依赖抽象为**服务** (service): - -- 任何插件都可以声明自己依赖的服务列表 -- 服务存在明确的生命周期(提供、撤销) -- 运行时对依赖不满足的插件**等待**,而非拒绝 -- 服务生命周期结束前,依赖该服务的插件**先一步被回收** - -```typescript -// LLM 适配器插件:提供 llm 服务 -export class LlmService extends Service { - static inject = ['http'] // 自身依赖 http - // 当 http 不可用时,LlmService 自动挂起 - // 挂起导致 ctx.llm 不可用 - // 所有 inject: ['llm'] 的插件级联挂起 -} -``` - -## 与现有理论的对比 - -### 与 Comonad 余作用比较 - -基于 Comonad 的余作用(Petricek 2013)将上下文建模为静态结构,侧重于编译期分析。Cordis 的响应式余作用额外引入了**时序语义**: - -- 服务可在运行时出现/消失 -- 依赖关系随之动态建立/解除 -- 效果的生命周期由依赖关系决定 - -### 与 Grade Algebra 余作用比较 - -基于 Grade Algebra 的余作用(Gaboardi 2016)用有序半环描述资源的组合规则。Cordis 的服务依赖可以建模为**交换半群**: - -- 服务名构成依赖集合 -- 集合并(∪)对应并行依赖 -- 交换律:依赖 A + B ≡ 依赖 B + A(声明顺序无关) -- 结合律:依赖分组方式不影响语义 - -但 Cordis 还增加了代数不具备的运行时行为:当集合中的某个服务不可用时,整个依赖集不满足,触发挂起。 - -## 在 Cordis 中的实现 - -```typescript -// 声明依赖 -export const inject = ['tools', 'llm'] - -export function apply(ctx: Context) { - // 到这里时,ctx.tools 和 ctx.llm 一定可用 - // 如果任一服务消失,此插件自动卸载 - // 服务恢复后,自动重新执行 apply -} -``` - -服务生命周期变化时的行为: - -``` -llm service 可用 → 依赖 llm 的插件 PENDING → ACTIVE -llm service 消失 → 依赖 llm 的插件 ACTIVE → DISPOSED -llm service 恢复 → 依赖 llm 的插件重新 PENDING → ACTIVE -``` - -## 为什么 Agent 需要响应式余作用 - -在 Harness 场景下,响应式余作用直接支撑: - -| 场景 | 行为 | -|------|------| -| LLM adapter 热替换 | 依赖 `llm` 的插件自动挂起/恢复,中间不丢状态 | -| 按需加载 bash 执行器 | bash tool 只在 `bash` 服务就绪后注册 | -| 子 Agent 独立服务空间 | 通过 `ctx.isolate()` 隔离服务实例,互不干扰 | -| 可选能力降级 | `inject: { web: { required: false } }` 允许 web 不可用时继续运行 | - -这意味着 Harness 插件开发者无需编写防御性的 "if service exists" 检查——框架保证:当你的 `apply` 被调用时,声明的依赖一定已就绪。 diff --git a/website/zh-CN/design/revertible-effects.md b/website/zh-CN/design/revertible-effects.md deleted file mode 100644 index 5133400e75..0000000000 --- a/website/zh-CN/design/revertible-effects.md +++ /dev/null @@ -1,128 +0,0 @@ -# 可逆作用 - -可逆作用 (Revertible Effects) 是 Cordis 实现**时间可组合性**的核心机制。 - -- 在单子作用的基础上增加可逆性约束 -- 提供面向长时运行程序的作用系统 -- 确保程序可以在插件粒度上回到任意状态 - -## 副作用的封装 - -现实中的程序需要与各种副作用打交道。假设一个不纯函数: - -$$ -f_\text{impure}: \text{X}\to\text{Y} -$$ - -我们将所有可能的副作用用类型 $\mathcal{C}$ 封装,函数变为: - -$$ -f: \mathcal{C}\times\text{X}\to\mathcal{C}\times\text{Y} -$$ - -对于长时运行程序,忽略函数本身的入参和出参,$f$ 属于函数空间 $\mathfrak{F}=\mathcal{C}\to\mathcal{C}$。 - -## 从幺半群到群 - -任何函数 $f: \mathcal{C}\to\mathcal{C}$ 都是状态空间到自身的变换。在组合 $\circ$ 下构成**幺半群**: - -1. 封闭性:$f\circ g$ 也是 $\mathcal{C}\to\mathcal{C}$ -2. 结合律:$(f\circ g)\circ h=f\circ (g\circ h)$ -3. 单位元:$\text{id}$,使得 $f\circ\text{id}=\text{id}\circ f=f$ - -如果额外要求每个 $f$ 存在逆元 $f^{-1}$(即副作用可回收),$\mathfrak{F}$ 升级为**群**。 - -## 副作用都可逆吗? - -观察计算机中的副作用模式: - -| 操作 | 占用资源 | 逆操作 | -|------|----------|--------| -| 打开文件 | 文件描述符 | 关闭文件 | -| 创建子进程 | 进程号 | 杀死进程 | -| 监听端口 | 端口 | 取消监听 | -| 添加回调函数 | 事件槽位 | 删除回调 | -| 分配内存 | 内存区块 | 回收内存 | - -**副作用就是对资源的占用。** 计算机的资源天然设计为可重复使用,因此这些副作用一定是可逆的。 - -## 追踪和回收副作用 - -Cordis 通过 $\text{effect}$ 和 $\text{restore}$ 函子追踪和回收逆函数。 - -### effect 函子 - -$$ -\begin{array}{} -\text{effect}&:& -\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ -\text{effect}&=&f&\mapsto&\left(c, h\right)&\mapsto&\left(f(c), h\circ f^{-1}\right) -\end{array} -$$ - -直觉:执行 $f$ 产生的副作用记入状态 $c$,同时将逆操作 $f^{-1}$ 追加到回收链 $h$ 中。 - -### 同态性证明 - -$\text{effect}$ 是从 $\mathcal{C}\to\mathcal{C}$ 到 $\mathcal{C}\times(\mathcal{C}\to\mathcal{C})\to\mathcal{C}\times(\mathcal{C}\to\mathcal{C})$ 的同态: - -$$ -\begin{aligned} -\text{effect}\ (f\circ g) \left(c, h\right) -&=\left((f\circ g)(c), h\circ (f\circ g)^{-1}\right)\\ -&=\left(f(g(c)), h\circ g^{-1}\circ f^{-1}\right)\\ -&=\left(\text{effect}\ f\right)\left(g(c), h\circ g^{-1}\right)\\ -&=\left(\text{effect}\ f\right)\circ\left(\text{effect}\ g\right) \left(c, h\right) -\end{aligned} -$$ - -这意味着:组合两个操作后再追踪 = 分别追踪后再组合。副作用追踪与执行顺序无关。 - -### restore 函子 - -$$ -\begin{array}{} -\text{restore}&:& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ -\text{restore}&=&\left(c, h\right)&\mapsto&\left(h(c),\text{id}\right) -\end{array} -$$ - -直觉:将回收链 $h$ 应用到当前状态,一次性回收所有已追踪的副作用。 - -## 在 Cordis 中的实现 - -理论映射到 API: - -| 数学概念 | Cordis API | 说明 | -|----------|-----------|------| -| $\text{effect}(f)$ | `ctx.effect(() => { ...; return dispose })` | 注册副作用并返回清理函数 | -| $\text{restore}$ | `fiber.dispose()` | 执行 Fiber 的整个回收链 | -| $f^{-1}$ | dispose 返回值 / cleanup 函数 | 逆操作 | - -```typescript -export function apply(ctx: Context) { - // effect: 创建资源,返回其逆操作 - ctx.effect(() => { - const server = startServer(8080) // f: 占用端口 - return () => server.close() // f⁻¹: 释放端口 - }) - - // 框架 API 内部已封装 effect - ctx.on('event', handler) // 内部: effect(addListener, removeListener) - ctx.tools.register(myTool) // 内部: effect(addTool, removeTool) -} -// 当此插件被卸载时,restore 自动按逆序执行所有 f⁻¹ -``` - -## 为什么 Agent 需要可逆作用 - -在 Harness 场景下,可逆作用直接支撑: - -- **热替换 LLM 适配器**:卸载旧适配器(回收注册)、加载新适配器,无需重启 -- **动态 tool 管理**:根据对话上下文动态添加/移除 tool,不泄漏 -- **子 Agent 生命周期**:子 Agent 完成后,其注册的所有临时 tool 和监听器自动清理 -- **优雅关闭**:进程退出时所有插件按依赖逆序 dispose,确保资源完全释放 diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md deleted file mode 100644 index d555a0a478..0000000000 --- a/website/zh-CN/guide/config.md +++ /dev/null @@ -1,342 +0,0 @@ -# 配置文件 - -Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参数运行。 - -## 从例子开始 - -### echo-agent 的配置 - -这是一开始的第一个 Agent 的完整配置: - -```yaml -# 热替换:修改代码后自动重载,不用手动重启 -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# Mock 模型:从本地 `.ts` 文件加载,注册一个名为 `mock-llm` 的工具 -# 本地模拟 LLM 响应,不联网 -- id: mock-llm - name: './src/mock-llm.ts' - -# Echo 工具:收到文本后转大写返回 -- id: echo-tool - name: './src/echo-tool.ts' - -# Bash 执行器:从 npm 包 `@deepseek-ai/dsh-bash-local`加载,提供 bash 命令执行能力 -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -# 应用主体:把 session 管理、tool 调度、agent loop 等组装成一个可交互的终端 Agent -# 只需告诉它用哪个模型 (`model`)、什么人设 (`persona`) -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: mock-echo - persona: 'You are echo-agent, a demo agent.' - welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' - persistenceRoot: './.sessions' -``` - -### coding-agent 的配置 - -真实场景——接入 DeepSeek API,带完整工具链: - -```yaml -# 热替换:同上,开发时自动重载 -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 -# `!!js` 从环境变量读取密钥,不会写进配置文件 -# `models` 声明该适配器能处理哪些模型名 -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-pro - - deepseek-v4-flash - -# Bash 执行器:让 Agent 能跑 shell 命令 -# timeoutMs 设置单条命令的超时时间 -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# 应用主体:和 echo-agent 一样的框架,只是配置不同 -# `model` 指定默认使用哪个模型(要和上面 models 列表里的名字对应) -# `persona` 是系统提示词,{{model}} 会被替换为实际模型名 -# `resumeSessionId` 设了就恢复旧对话,没设就每次新建 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - welcome: 'agent REPL ready. Give it a coding task.' - persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. - Verify your work by running the code or tests. Keep answers brief and factual. - -# 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 -# contextWindow 是模型能看到的 token 上限 -# thresholdRatio 超过这个比例就触发压缩 -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - config: - contextWindow: 128000 - thresholdRatio: 0.8 - retainTokens: 20480 - maxTokens: 8192 - -# 子代理:把子任务分配给独立的 Agent 去做 -# subagent 是服务注册,spawn/fork 是两种委派方式: -# spawn — 全新子代理,不知道父级在聊什么 -# fork — 继承父级对话上下文的子代理 -# tool-subagent 把委派能力暴露给模型,toolName 是模型看到的工具名 -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -# 任务追踪:模型可以用 todo_write 记录和更新任务清单 -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# 文件系统:让 Agent 能读写编辑文件 -# fs-local 提供本地文件操作能力,cwd 是工作目录 -# fs-policy 是安全策略——必须先读才能写,防止模型盲写 -# tool-fs 把能力暴露给模型(read / write / edit 三个工具) -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' -``` - -和 echo-agent 对比:同一个 `dsh-stdio-agent` 应用主体,只是把 mock 换成了真实 API,加上了更多工具插件。 - -## 语法详解 - -### 插件声明字段 - -每个插件条目支持以下字段: - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `name` | string | 是 | 插件来源(npm 包名或相对路径) | -| `id` | string | 否 | 实例标识符,用于日志和调试 | -| `config` | object | 否 | 传递给插件的配置 | -| `disabled` | boolean | 否 | 设为 `true` 临时禁用该插件 | - -### 插件来源 (`name`) - -**npm 包** — 已安装的 `@deepseek-ai/dsh-*` 包或第三方包: - -```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' -``` - -**相对路径** — 本地 TypeScript 文件(相对于 `cordis.yml` 所在目录): - -```yaml -- name: './src/my-tool.ts' -``` - -### 环境变量 (`!!js`) - -用 `!!js` 标签在配置中引用运行时表达式: - -```yaml -config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - cwd: !!js process.cwd() -``` - -::: warning -是 `!!js`(两个感叹号),不是 `!js`。写错了会静默失败。 -::: - -环境变量从仓库根目录的 `.env` 文件自动加载(已被 gitignore)。 - -### 禁用插件 - -不想删配置但暂时不加载?加一行 `disabled`: - -```yaml -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - disabled: true - config: - contextWindow: 128000 -``` - -## 各插件配置参考 - -### stdio-agent(标准应用主体) - -**包名:** `@deepseek-ai/dsh-stdio-agent` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `model` | string | **必填** | 使用的模型名,需与 LLM 适配器注册的名字一致 | -| `persona` | string | `''` | 系统提示词。支持 `{{model}}` 等模板变量 | -| `toolOrder` | string[] | — | 模型看到的工具顺序。省略则按字母排序 | -| `persistenceRoot` | string | `'./.sessions'` | 会话日志存储目录 | -| `welcome` | string | `'ready.'` | 启动时显示的欢迎信息 | -| `resumeSessionId` | string | — | 恢复指定会话 ID。留空则每次新建 | - -### llm-deepseek(DeepSeek 适配器) - -**包名:** `@deepseek-ai/dsh-llm-deepseek` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | -| `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | -| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | -| `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | -| `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | - -### bash-local(Bash 执行器) - -**包名:** `@deepseek-ai/dsh-bash-local` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `cwd` | string | `process.cwd()` | 命令执行的工作目录 | -| `timeoutMs` | number | `120000` | 单条命令的超时时间(毫秒) | -| `maxTimeoutMs` | number | `600000` | 单条命令超时的上限(模型不能请求更久) | -| `maxOutputBytes` | number | `64000` | 单次输出的内存上限(超出后溢出到临时文件) | -| `graceMs` | number | `3000` | kill 时从 SIGTERM 到 SIGKILL 的等待时间 | - -### compact-basic(自动压缩) - -**包名:** `@deepseek-ai/dsh-compact-basic` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | -| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | -| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | -| `maxTokens` | number | **必填** | 总结时的最大输出 token | -| `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | -| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | -| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | -| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | - -### fs-local(文件系统) - -**包名:** `@deepseek-ai/dsh-fs-local` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `cwd` | string | `process.cwd()` | 工作目录,相对路径以此为基准 | - -### fs-policy(文件系统策略) - -**包名:** `@deepseek-ai/dsh-fs-policy` - -无配置项。加载即启用"必须先读才能写"的安全策略。 - -### tool-fs(文件系统工具) - -**包名:** `@deepseek-ai/dsh-tool-fs` - -无配置项。加载后向模型暴露 `read`、`write`、`edit` 三个工具。 - -### tool-web(Web 工具) - -**包名:** `@deepseek-ai/dsh-tool-web` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `search` | boolean | `true` | 是否注册 `web_search` 工具 | -| `fetch` | boolean | `true` | 是否注册 `web_fetch` 工具 | -| `searchMaxResults` | number | `8` | 单次搜索返回的最大结果数 | - -### subagent-spawn / subagent-fork(子代理后端) - -**包名:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `providerName` | string | `'spawn'` / `'fork'` | 注册到子代理服务的 provider 名称 | - -### tool-subagent(子代理工具) - -**包名:** `@deepseek-ai/dsh-tool-subagent` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `provider` | string | **必填** | 使用哪个 provider(如 `spawn`、`fork`) | -| `toolName` | string | `'subagent'` | 暴露给模型的工具名。多次加载时必须不同 | -| `agentOptions.model` | string | — | 子代理使用的模型名(省略则继承父代理) | - -### tool-todo(任务清单) - -**包名:** `@deepseek-ai/dsh-tool-todo` - -无配置项。加载后向模型暴露 `todo_write` 工具。 - -### hmr(热替换) - -**包名:** `@cordisjs/plugin-hmr` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `root` | string[] | **必填** | 监听文件变更的目录列表 | - -::: tip -hmr 仅用于开发环境。它需要 `node --expose-internals` 启动参数,`demo:*` 脚本已自动添加。 -::: - ---- - -## 加载顺序 - -`cordis.yml` 的顺序就是加载顺序。推荐: - -1. **hmr** — 热替换(仅开发时需要) -2. **LLM 适配器** — 模型后端 -3. **执行器** — bash、fs 等能力提供者 -4. **应用主体** — `dsh-stdio-agent` 或 `dsh-acp-agent` -5. **附加插件** — compact、subagent、todo 等 - -应用主体内部已经捆绑了核心能力(session、tools、agent-loop),不需要手动加载。 - -## 下一步 - -- [开发插件](../develop/basic/) — 编写自己的插件 -- [API 参考](../api/) — 查看各插件完整接口 From 341b56ebc3b2fcd0d01d519bb0aa9c25847441e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 15:51:35 +0800 Subject: [PATCH 006/132] docs(skill): add documentation site sync workflow --- .agents/skills/dsh-doc-site-sync/SKILL.md | 80 +++++++++++++++++++ .../dsh-doc-site-sync/agents/openai.yaml | 4 + 2 files changed, 84 insertions(+) create mode 100644 .agents/skills/dsh-doc-site-sync/SKILL.md create mode 100644 .agents/skills/dsh-doc-site-sync/agents/openai.yaml diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md new file mode 100644 index 0000000000..0fdd9b9398 --- /dev/null +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -0,0 +1,80 @@ +--- +name: dsh-doc-site-sync +description: Use when publishing, updating, moving, or removing DeepSeek Harness documentation website pages; editing website/docs.ts mappings or navigation; diagnosing a page missing from the VitePress site; fixing projected documentation links; or running the docs:dev, docs:check, and doc-sync workflow after website-content changes. +--- + +# Synchronizing the DeepSeek Harness Documentation Site + +Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. + +## Read the owning contracts + +- Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. +- Use [dsh-translate-docs](../dsh-translate-docs/SKILL.md) whenever an edited source has a bilingual counterpart. +- Read the current `DocsPage` type and entries in [website/docs.ts](../../../website/docs.ts) before changing the manifest; do not rely on a remembered field set. +- Read [website/.vitepress/config.ts](../../../website/.vitepress/config.ts) before adding a new section, sidebar collection, locale, or top-level navigation item. + +## Classify the change + +- **Edit an already published page:** change only its canonical Markdown source. Do not touch the manifest unless its route or navigation metadata changes. +- **Publish a new page:** create it in its owning `docs/` tier, then add one manifest entry. +- **Rename, move, or remove a page:** update the canonical file, manifest entry, and inbound repository links atomically. Remove stale manifest entries; `docs:check` rejects missing sources. +- **Publish a generated catalog:** map the generated `docs/` file, but change its generator or source metadata rather than editing the catalog by hand. +- **Change site structure:** update the manifest for ordinary pages; update VitePress configuration only when the existing sidebar, section, or locale model cannot express the change. + +Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist/`. Never copy a maintained `docs/` page into `website/`. + +## Add or update a manifest entry + +Set every `DocsPage` field deliberately: + +- `source`: repository-relative canonical Markdown path. +- `route`: public VitePress path including the `.md` suffix. +- `label`: sidebar label, not necessarily the document H1. +- `sidebar`: reuse `zh-guide`, `zh-develop`, or `en-docs` unless the information architecture genuinely needs another collection. +- `section`: reuse an existing section when possible. If adding one, also place it in `sectionOrder` in the VitePress config. +- `order`: stable order within the section. +- `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. + +Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. + +## Preserve link behavior + +Write normal repository-relative Markdown links in canonical docs. The projector applies these rules: + +- A target present in the manifest becomes a site-relative route. +- An existing target outside the manifest becomes a GitHub source link, including supported line suffixes. +- External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged. +- A missing repository-relative target fails projection instead of silently producing a broken link. + +Do not write website-specific routes into canonical Markdown just to satisfy VitePress. Use `sourceAliases` for directory-style repository links that should resolve to a mapped index page. + +## Preview and validate + +Run local preview while editing: + +```sh +pnpm docs:dev +``` + +The dev server watches mapped source files and reprojects them. Restart it after changing the manifest if the new source is not picked up automatically. + +Run the focused website gate before treating the mapping as valid: + +```sh +pnpm docs:check +``` + +Before committing a documentation-site change, run: + +```sh +pnpm run doc-sync +pnpm run lint +git diff --check +``` + +Use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md) before pushing. Report the canonical files changed, manifest entries added or removed, public routes affected, and the exact checks run. + +## Keep deployment separate + +Synchronizing content into the VitePress build does not publish it to the internet. Do not add GitHub Pages permissions, deployment workflows, custom domains, or public hosting unless the user explicitly requests deployment and confirms the hosting policy. diff --git a/.agents/skills/dsh-doc-site-sync/agents/openai.yaml b/.agents/skills/dsh-doc-site-sync/agents/openai.yaml new file mode 100644 index 0000000000..9f4909f258 --- /dev/null +++ b/.agents/skills/dsh-doc-site-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "DSH Documentation Site Sync" + short_description: "Publish repository docs through the DSH website manifest" + default_prompt: "Use $dsh-doc-site-sync to publish or update a DeepSeek Harness documentation page on the website." From 6be219a0bc7cd2554282fc41a565b5c399271071 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 17:47:42 +0800 Subject: [PATCH 007/132] fix(docs): address documentation site review --- docs/user/zh-CN/develop/basic/config.md | 17 +- docs/user/zh-CN/develop/basic/index.md | 13 +- docs/user/zh-CN/develop/basic/tool.md | 12 +- docs/user/zh-CN/develop/framework/events.md | 50 ++- docs/user/zh-CN/develop/framework/index.md | 12 +- docs/user/zh-CN/develop/framework/service.md | 17 +- .../zh-CN/develop/practice/llm-adapter.md | 7 +- docs/user/zh-CN/index.md | 2 +- scripts/project-doc-site.spec.ts | 37 ++- scripts/project-doc-site.ts | 18 +- website/.vitepress/config.ts | 61 +++- website/docs.ts | 299 +++++++++++------- 12 files changed, 333 insertions(+), 212 deletions(-) diff --git a/docs/user/zh-CN/develop/basic/config.md b/docs/user/zh-CN/develop/basic/config.md index 6c5bf6c651..23294f7955 100644 --- a/docs/user/zh-CN/develop/basic/config.md +++ b/docs/user/zh-CN/develop/basic/config.md @@ -4,10 +4,11 @@ ## 定义 Config 类型 -在插件中导出一个 `Config` 类型和可选的默认值: +在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: ```typescript import type { Context } from 'cordis' +import Schema from 'schemastery' export const name = 'my-plugin' @@ -17,11 +18,11 @@ export interface Config { verbose?: boolean } -export const Config = { - greeting: 'Hello', - maxRetries: 3, - verbose: false, -} +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + maxRetries: Schema.number().default(3), + verbose: Schema.boolean().default(false), +}) export function apply(ctx: Context, config: Config) { console.log(config.greeting) // 用户配置或默认值 @@ -37,7 +38,7 @@ export function apply(ctx: Context, config: Config) { maxRetries: 5 ``` -未提供的字段使用导出的 `Config` 对象中的默认值。 +插件加载时,Cordis 会通过导出的 schema 校验配置,并填充未提供字段的默认值。不要导出普通对象作为 `Config`,因为它不满足 Cordis 要求的 Standard Schema 接口。 ## Schema 校验 @@ -92,7 +93,7 @@ export interface Config { ```typescript export function apply(ctx: Context, config: Config) { - if (!ctx.llm.hasAdapter(config.model)) { + if (!ctx.llm.models().includes(config.model)) { throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) } } diff --git a/docs/user/zh-CN/develop/basic/index.md b/docs/user/zh-CN/develop/basic/index.md index ce68283892..c1f7ab800b 100644 --- a/docs/user/zh-CN/develop/basic/index.md +++ b/docs/user/zh-CN/develop/basic/index.md @@ -28,10 +28,8 @@ import type { Context } from 'cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { - // 监听 agent-loop 的 ready 事件 - ctx.on('ready', () => { - console.log('[hello-plugin] 插件已加载!') - }) + // apply 被调用时,插件的必选依赖已就绪 + console.log('[hello-plugin] 插件已加载!') } ``` @@ -100,17 +98,14 @@ export default { ### 类形式 ```typescript -import { Service } from 'cordis' +import { Service, type Context } from 'cordis' export default class MyService extends Service { static inject = ['tools'] constructor(ctx: Context) { super(ctx, 'myService') - } - - start() { - // 服务启动逻辑 + // 构造函数内完成同步初始化 } } ``` diff --git a/docs/user/zh-CN/develop/basic/tool.md b/docs/user/zh-CN/develop/basic/tool.md index 47a3af7867..9eb4715385 100644 --- a/docs/user/zh-CN/develop/basic/tool.md +++ b/docs/user/zh-CN/develop/basic/tool.md @@ -133,14 +133,14 @@ defineTool({ // ... presentCall(args) { return { - intent: 'terminal', - title: `bash(${JSON.stringify(args.command).slice(0, 60)})`, + card: 'terminal', + title: args.command, } }, presentResult(args, result) { return { - intent: 'terminal', - body: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + card: 'terminal', + output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), } }, }) @@ -156,9 +156,7 @@ defineTool({ // 这样就够了: ctx.tools.register(defineTool({ /* ... */ })) -// 不需要: -// const dispose = ctx.tools.register(...) -// ctx.on('dispose', dispose) +// 不需要额外保存 disposer 或注册清理逻辑 ``` ## 完整实战示例 diff --git a/docs/user/zh-CN/develop/framework/events.md b/docs/user/zh-CN/develop/framework/events.md index 641c63b0f8..80f49d38dc 100644 --- a/docs/user/zh-CN/develop/framework/events.md +++ b/docs/user/zh-CN/develop/framework/events.md @@ -24,15 +24,15 @@ Cordis 提供多种事件触发模式,适用于不同场景: ### emit — 广播 -所有监听器并行执行,不关心返回值: +所有监听器同步执行,不关心返回值: ```typescript // 触发 -ctx.emit('agent/turn-end', { agentId, turnIndex }) +ctx.emit('my-plugin/ready', { id: 'worker-1' }) // 监听 -ctx.on('agent/turn-end', ({ agentId, turnIndex }) => { - console.log(`Turn ${turnIndex} ended`) +ctx.on('my-plugin/ready', ({ id }) => { + console.log(`${id} is ready`) }) ``` @@ -53,7 +53,7 @@ ctx.on('some-check', (input) => { ### serial — 顺序执行 -所有监听器按注册顺序依次执行(异步安全): +监听器按注册顺序依次执行,并等待异步结果;第一个返回非空值的监听器会终止后续执行: ```typescript await ctx.serial('setup-phase', context) @@ -61,18 +61,16 @@ await ctx.serial('setup-phase', context) ### waterfall — 管道 -每个监听器接收前一个的输出,形成数据管道。**必须调用 `next()` 传递给下游**,不调用即为否决: +每个监听器可以包装下游返回值,形成处理链。**必须调用 `next()` 传递给下游**,不调用即为否决: ```typescript // 触发 -const finalMessages = await ctx.waterfall('llm/pre-request', messages) +const output = await ctx.waterfall('my-plugin/transform', input, async () => input) // 监听(必须调用 next) -ctx.on('llm/pre-request', async (messages, next) => { - // 可以修改 messages - messages.push(extraMessage) - // 必须调用 next() 传递给下一个监听器 - return next(messages) +ctx.on('my-plugin/transform', async (_input, next) => { + const downstream = await next() + return downstream.trim() }) ``` @@ -89,6 +87,7 @@ declare module 'cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined + 'my-plugin/transform': (input: string, next: () => Promise) => Promise } } @@ -96,20 +95,11 @@ declare module 'cordis' { // 都有正确的类型推导 ``` -## 命名约定 +## Cordis 事件与会话记录 -Harness 事件遵循 `namespace/action` 命名: +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../../cordis-catalog/events.md)。 -``` -agent/pre-step — agent 执行一步之前 -agent/post-step — agent 执行一步之后 -tool/call — tool 被调用 -tool/result — tool 返回结果 -llm/pre-request — LLM 请求发送前 -session/event — 会话事件被记录 -compact/start — 压缩开始 -compact/end — 压缩结束 -``` +`turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 ## 事件也是效果 @@ -118,7 +108,7 @@ compact/end — 压缩结束 ```typescript export function apply(ctx: Context) { // 这个监听器在插件 dispose 时自动清理 - ctx.on('agent/turn-end', handler) + ctx.on('tools/result', handler) } ``` @@ -132,14 +122,10 @@ import type { Context } from 'cordis' export const name = 'tool-logger' export function apply(ctx: Context) { - ctx.on('tool/call', ({ name, args }) => { - console.log(`[tool] ${name}(${JSON.stringify(args)})`) - }) - - ctx.on('tool/result', ({ name, result }) => { + ctx.on('tools/result', (exec, result) => { + console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`) const text = result.content - .filter(b => b.type === 'text') - .map(b => b.text) + .map(block => block.type === 'text' ? block.text : '') .join('') console.log(`[tool result] ${text.slice(0, 100)}`) }) diff --git a/docs/user/zh-CN/develop/framework/index.md b/docs/user/zh-CN/develop/framework/index.md index b0547be61b..a3fdd502b5 100644 --- a/docs/user/zh-CN/develop/framework/index.md +++ b/docs/user/zh-CN/develop/framework/index.md @@ -105,14 +105,6 @@ fiber.dispose() export function apply(ctx: Context) { console.log('plugin loading') - ctx.on('ready', () => { - console.log('context ready') - }) - - ctx.on('dispose', () => { - console.log('plugin disposing') - }) - ctx.effect(() => { console.log('effect registered') return () => console.log('effect cleaned up') @@ -124,12 +116,10 @@ export function apply(ctx: Context) { ``` plugin loading effect registered -context ready ``` -卸载时输出(逆序): +卸载时输出: ``` -plugin disposing effect cleaned up ``` diff --git a/docs/user/zh-CN/develop/framework/service.md b/docs/user/zh-CN/develop/framework/service.md index 17b9cb4e4e..19edf4a975 100644 --- a/docs/user/zh-CN/develop/framework/service.md +++ b/docs/user/zh-CN/develop/framework/service.md @@ -90,8 +90,11 @@ export default class MetricsService extends Service { // 必选:服务不存在时,插件不会加载 export const inject = ['tools'] -// 可选:服务不存在时,插件仍然加载,但 ctx.xxx 可能是 undefined -export const inject = { optional: ['metrics'] } +// 可选:不写入 inject,使用时通过 ctx.get() 查询 +export function apply(ctx: Context) { + const metrics = ctx.get('metrics') + metrics?.record('plugin_loaded', 1) +} ``` ### 服务消失时的行为 @@ -109,7 +112,10 @@ export const inject = { optional: ['metrics'] } ```yaml - id: group-a - name: 'group:' + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true config: - name: '@deepseek-ai/dsh-bash-local' config: @@ -117,7 +123,10 @@ export const inject = { optional: ['metrics'] } - name: './src/plugin-a.ts' - id: group-b - name: 'group:' + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true config: - name: '@deepseek-ai/dsh-bash-local' config: diff --git a/docs/user/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md index 20b1fa2c88..0b0ae3cff0 100644 --- a/docs/user/zh-CN/develop/practice/llm-adapter.md +++ b/docs/user/zh-CN/develop/practice/llm-adapter.md @@ -114,6 +114,8 @@ interface GenerateOptions { maxTokens?: number /** 温度 */ temperature?: number + /** 取消或卸载时中止进行中的请求 */ + signal?: AbortSignal } ``` @@ -160,7 +162,10 @@ mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地 ```typescript async *stream(options: GenerateOptions): AsyncIterable { - const response = await fetch(this.endpoint, { /* ... */ }) + const response = await fetch(this.endpoint, { + // ...method、headers 和 body + signal: options.signal, + }) if (!response.ok) { throw new Error(`API error: ${response.status}`) } diff --git a/docs/user/zh-CN/index.md b/docs/user/zh-CN/index.md index cbf700e41e..1c495125c6 100644 --- a/docs/user/zh-CN/index.md +++ b/docs/user/zh-CN/index.md @@ -13,7 +13,7 @@ hero: link: /develop/basic/ features: - title: 插件化架构 - details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。 + details: 基于 Cordis 插件系统,所有能力通过插件注册,加载即生效、卸载即还原。 - title: 配置即组合 details: 一个 cordis.yml 决定整个 Agent 的能力组合——换模型、加工具,只需改一行配置。 - title: 开箱即用 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index f44b779d80..9a6162576d 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { DocsPage } from '../website/docs.ts' +import { docsPages, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' const roots: string[] = [] @@ -25,8 +25,10 @@ function fixture(): { root: string; pages: DocsPage[] } { return { root, pages: [ - { source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-docs', section: 'Test', order: 1 }, - { source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-docs', section: 'Test', order: 2 }, + { locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 }, + { locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 }, + { locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 }, + { locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 }, ], } } @@ -36,6 +38,7 @@ describe('rewriteMarkdown', () => { const { root, pages } = fixture() const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n' expect(rewriteMarkdown(source, { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -48,9 +51,22 @@ describe('rewriteMarkdown', () => { ) }) + it('selects the published target in the current site locale', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('[B](b.md)\n', { + locale: 'root', + sourcePath: 'docs/a.md', + route: 'a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('[B](./reference-root/b.md)\n') + }) + it('uses raw GitHub content for unpublished images', () => { const { root, pages } = fixture() expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -63,6 +79,7 @@ describe('rewriteMarkdown', () => { const { root, pages } = fixture() const source = '```md\n[B](b.md)\n```\n' expect(rewriteMarkdown(source, { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -74,6 +91,7 @@ describe('rewriteMarkdown', () => { it('fails loud when a relative target is missing', () => { const { root, pages } = fixture() expect(() => rewriteMarkdown('[missing](missing.md)\n', { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -83,6 +101,19 @@ describe('rewriteMarkdown', () => { }) }) +describe('docsPages locale routes', () => { + it('publishes the same canonical source at every corresponding locale route', () => { + const byRoute = new Map(docsPages.map(page => [page.route, page])) + for (const page of docsPages.filter(page => page.locale === 'root')) { + const counterpart = byRoute.get(`en/${page.route}`) + expect(counterpart, page.route).toBeDefined() + expect(counterpart?.locale).toBe('en') + expect(counterpart?.source).toBe(page.source) + expect(counterpart?.contentLocale).toBe(page.contentLocale) + } + }) +}) + describe('addProjectionFrontmatter', () => { it('adds frontmatter to an ordinary Markdown page', () => { expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe( diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 89e35dfdc8..7ef35a6d28 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -11,7 +11,7 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' -import { docsPages, type DocsPage } from '../website/docs.ts' +import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' const root = resolve(import.meta.dirname, '..') @@ -25,6 +25,7 @@ interface Replacement { /** Inputs for rewriting one canonical Markdown page. */ export interface RewriteMarkdownOptions { + locale: DocsLocale sourcePath: string route: string pages: DocsPage[] @@ -62,14 +63,16 @@ function routeTarget(fromRoute: string, toRoute: string, suffix: string): string return `${target.startsWith('.') ? target : `./${target}`}${suffix}` } -function sourceMap(pages: DocsPage[]): Map { - const map = new Map() +function sourceMap(pages: DocsPage[]): Map> { + const map = new Map>() for (const page of pages) { for (const source of [page.source, ...(page.sourceAliases ?? [])]) { - if (map.has(source)) { - throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)}.`) + const localized = map.get(source) ?? new Map() + if (localized.has(page.locale)) { + throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`) } - map.set(source, page) + localized.set(page.locale, page) + map.set(source, localized) } } return map @@ -132,7 +135,7 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) - const page = published.get(targetPath) + const page = published.get(targetPath)?.get(options.locale) const nextUrl = page === undefined ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') : routeTarget(options.route, page.route, suffix) @@ -205,6 +208,7 @@ export function projectDocs(): void { const markdown = readFileSync(sourceAbs, 'utf8') const projected = rewriteMarkdown(markdown, { sourcePath: page.source, + locale: page.locale, route: page.route, pages: docsPages, repoRoot: root, diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 71c276794a..b553d6b2da 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -13,6 +13,14 @@ const sectionOrder = [ '基础', '框架能力', '实战', + '概念', + '生成参考', + '数据结构', + '开发手册', + 'Guide', + 'Basics', + 'Framework', + 'Practice', 'Concepts', 'Generated reference', 'Data structures', @@ -20,7 +28,7 @@ const sectionOrder = [ ] function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { - const pages = docsPages.filter(page => page.sidebar === collection && page.route !== 'index.md') + const pages = docsPages.filter(page => page.sidebar === collection) const sections = new Map() for (const page of pages) { const entries = sections.get(page.section) ?? [] @@ -51,7 +59,36 @@ function escapeVueInterpolation(html: string): string { } const sharedTheme: Pick = { - search: { provider: 'local' }, + search: { + provider: 'local', + options: { + locales: { + root: { + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档', + }, + modal: { + displayDetails: '显示详细列表', + resetButtonTitle: '清除搜索', + backButtonTitle: '关闭搜索', + noResultsText: '未找到相关结果', + footer: { + selectText: '选择', + selectKeyAriaLabel: '回车键', + navigateText: '切换', + navigateUpKeyAriaLabel: '上方向键', + navigateDownKeyAriaLabel: '下方向键', + closeText: '关闭', + closeKeyAriaLabel: 'Esc 键', + }, + }, + }, + }, + }, + }, + }, socialLinks: [ { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, ], @@ -81,14 +118,22 @@ export default withMermaid({ nav: [ { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, - { text: 'Reference', link: '/en/', activeMatch: '^/en/' }, + { text: '参考', link: '/reference/', activeMatch: '^/reference/' }, ], sidebar: { '/guide/': sidebar('zh-guide'), '/develop/': sidebar('zh-develop'), + '/reference/': sidebar('zh-reference'), }, outline: { label: '本页目录' }, docFooter: { prev: '上一篇', next: '下一篇' }, + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色主题', + darkModeSwitchTitle: '切换到深色主题', + sidebarMenuLabel: '菜单', + returnToTopLabel: '返回顶部', + langMenuLabel: '切换语言', + skipToContentLabel: '跳至内容', }, }, en: { @@ -97,12 +142,14 @@ export default withMermaid({ link: '/en/', themeConfig: { nav: [ - { text: 'Concepts', link: '/en/' }, - { text: 'Reference', link: '/en/config-catalog' }, - { text: '中文指南', link: '/guide/' }, + { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' }, + { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' }, + { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' }, ], sidebar: { - '/en/': sidebar('en-docs'), + '/en/guide/': sidebar('en-guide'), + '/en/develop/': sidebar('en-develop'), + '/en/reference/': sidebar('en-reference'), }, editLink: { pattern: ({ frontmatter }: PageData) => { diff --git a/website/docs.ts b/website/docs.ts index 53e74c2905..16d53e5b8a 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -1,20 +1,38 @@ /** * Canonical publication manifest for the documentation website. * - * Markdown stays in its owning repository tier. This manifest only maps a - * source file to its public route and navigation placement. + * Markdown stays in its owning repository tier. This manifest maps each + * canonical source into matching route trees for both site locales; when a + * translation is absent, both routes intentionally project the available + * source instead of copying Markdown. */ +/** Locale key used by the VitePress site. */ +export type DocsLocale = 'root' | 'en' + +/** Sidebar collection rendered for one locale and top-level module. */ +type DocsSidebar = + | 'zh-guide' + | 'zh-develop' + | 'zh-reference' + | 'en-guide' + | 'en-develop' + | 'en-reference' + /** A page projected into the VitePress source tree. */ export interface DocsPage { + /** VitePress locale whose route tree owns this projection. */ + locale: DocsLocale + /** Language of the canonical source currently projected at this route. */ + contentLocale: 'zh-CN' | 'en-US' /** Repository-relative canonical Markdown source. */ source: string /** VitePress route, including the `.md` suffix. */ route: string /** Navigation label shown in the sidebar. */ label: string - /** Sidebar collection that owns the page. */ - sidebar: 'zh-guide' | 'zh-develop' | 'en-docs' + /** Sidebar collection that owns the page, or null for a locale home page. */ + sidebar: DocsSidebar | null /** Section label within the sidebar. */ section: string /** Stable order within the section. */ @@ -23,191 +41,228 @@ export interface DocsPage { sourceAliases?: string[] } -const zhGuide: DocsPage[] = [ +interface MirroredPage { + source: string + route: string + contentLocale: DocsPage['contentLocale'] + label: Record + sidebar: Record + section: Record + order: number + sourceAliases?: string[] +} + +function mirroredPages(pages: MirroredPage[]): DocsPage[] { + return pages.flatMap(page => (['root', 'en'] as const).map(locale => ({ + locale, + contentLocale: page.contentLocale, + source: page.source, + route: locale === 'root' ? page.route : `en/${page.route}`, + label: page.label[locale], + sidebar: page.sidebar[locale], + section: page.section[locale], + order: page.order, + ...(page.sourceAliases === undefined ? {} : { sourceAliases: page.sourceAliases }), + }))) +} + +const homeAndGuide = mirroredPages([ { source: 'docs/user/zh-CN/index.md', route: 'index.md', - label: 'DeepSeek Harness', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: 'DeepSeek Harness', en: 'DeepSeek Harness' }, + sidebar: { root: null, en: null }, + section: { root: '首页', en: 'Home' }, order: 0, }, { source: 'docs/user/zh-CN/guide/index.md', route: 'guide/index.md', - label: '介绍', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '介绍', en: 'Introduction' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 1, sourceAliases: ['docs/user/zh-CN/guide'], }, { source: 'docs/user/zh-CN/guide/quickstart.md', route: 'guide/quickstart.md', - label: '快速开始', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '快速开始', en: 'Quick start' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 2, }, { source: 'docs/user/zh-CN/guide/config.md', route: 'guide/config.md', - label: '配置文件', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '配置文件', en: 'Configuration' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 3, }, -] +]) -const zhDevelop: DocsPage[] = [ +const develop = mirroredPages([ { source: 'docs/user/zh-CN/develop/basic/index.md', route: 'develop/basic/index.md', - label: '第一个插件', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '第一个插件', en: 'First plugin' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/basic'], }, { source: 'docs/user/zh-CN/develop/basic/tool.md', route: 'develop/basic/tool.md', - label: '开发一个 Tool', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '开发一个 Tool', en: 'Build a tool' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 2, }, { source: 'docs/user/zh-CN/develop/basic/config.md', route: 'develop/basic/config.md', - label: '插件配置', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '插件配置', en: 'Plugin configuration' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 3, }, { source: 'docs/user/zh-CN/develop/framework/index.md', route: 'develop/framework/index.md', - label: '插件与生命周期', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '插件与生命周期', en: 'Plugin lifecycle' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/framework'], }, { source: 'docs/user/zh-CN/develop/framework/service.md', route: 'develop/framework/service.md', - label: '服务与依赖', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '服务与依赖', en: 'Services and dependencies' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 2, }, { source: 'docs/user/zh-CN/develop/framework/events.md', route: 'develop/framework/events.md', - label: '事件系统', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '事件系统', en: 'Event system' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 3, }, { source: 'docs/user/zh-CN/develop/practice/index.md', route: 'develop/practice/index.md', - label: '能力的三层拆分', - sidebar: 'zh-develop', - section: '实战', + contentLocale: 'zh-CN', + label: { root: '能力的三层拆分', en: 'Capability layering' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '实战', en: 'Practice' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/practice'], }, { source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', route: 'develop/practice/llm-adapter.md', - label: 'LLM 适配器', - sidebar: 'zh-develop', - section: '实战', + contentLocale: 'zh-CN', + label: { root: 'LLM 适配器', en: 'LLM adapter' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '实战', en: 'Practice' }, order: 2, }, -] +]) -const enOverview: DocsPage[] = ([ - ['docs/architecture.md', 'en/index.md', 'Architecture'], - ['docs/cordis-primer.md', 'en/cordis-primer.md', 'Cordis primer'], - ['docs/capability-seams.md', 'en/capability-seams.md', 'Capability services'], - ['docs/agent-lifecycle.md', 'en/agent-lifecycle.md', 'Agent lifecycle'], - ['docs/tool-execution-pipeline.md', 'en/tool-execution-pipeline.md', 'Tool execution'], -] as const).map(([source, route, label], order) => ({ - source, - route, - label, - sidebar: 'en-docs', - section: 'Concepts', - order, -})) - -const enCatalogs: DocsPage[] = ([ - ['docs/config-catalog.md', 'en/config-catalog.md', 'Plugin configuration'], - ['docs/tool-catalog.md', 'en/tool-catalog.md', 'Tool schemas'], - ['docs/cordis-catalog/services.md', 'en/cordis-catalog/services.md', 'Services'], - ['docs/cordis-catalog/events.md', 'en/cordis-catalog/events.md', 'Events'], - ['docs/persistence-catalog.md', 'en/persistence-catalog.md', 'Persistence events'], -] as const).map(([source, route, label], order) => ({ - source, - route, - label, - sidebar: 'en-docs', - section: 'Generated reference', - order, -})) - -const corePages = [ - ['core.md', 'Core data structures'], - ['session.md', 'Sessions'], - ['tools.md', 'Tools'], - ['llm-streaming.md', 'LLM streaming'], - ['bash.md', 'Bash execution'], - ['filesystem.md', 'Filesystem'], - ['code-runtime.md', 'Code runtime'], - ['compaction.md', 'Compaction'], - ['subagent.md', 'Subagents'], - ['workflow.md', 'Workflows'], - ['skills.md', 'Skills'], - ['approval.md', 'Approvals'], - ['user-interaction.md', 'User interaction'], - ['sandbox.md', 'Sandboxing'], - ['web.md', 'Web access'], - ['persistence.md', 'Session persistence'], -] as const - -const enCore: DocsPage[] = corePages.map(([file, label], order) => ({ - source: `docs/core-data-structures/${file}`, - route: `en/core-data-structures/${file}`, - label, - sidebar: 'en-docs', - section: 'Data structures', - order, - ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), -})) - -const enCookbook: DocsPage[] = ([ - ['adding-a-package.md', 'Adding a package'], - ['adding-a-tool.md', 'Adding a tool'], - ['adding-an-llm-adapter.md', 'Adding an LLM adapter'], - ['extension-cookbook.md', 'Extension patterns'], -] as const).map(([file, label], order) => ({ - source: `docs/cookbook/${file}`, - route: `en/cookbook/${file}`, - label, - sidebar: 'en-docs', - section: 'Cookbook', - order, -})) +const reference = mirroredPages([ + ...([ + ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture'], + ['docs/cordis-primer.md', 'reference/cordis-primer.md', 'Cordis 入门', 'Cordis primer'], + ['docs/capability-seams.md', 'reference/capability-seams.md', '能力服务', 'Capability services'], + ['docs/agent-lifecycle.md', 'reference/agent-lifecycle.md', 'Agent 生命周期', 'Agent lifecycle'], + ['docs/tool-execution-pipeline.md', 'reference/tool-execution-pipeline.md', 'Tool 执行', 'Tool execution'], + ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + source, + route, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '概念', en: 'Concepts' }, + order, + })), + ...([ + ['docs/config-catalog.md', 'reference/config-catalog.md', '插件配置', 'Plugin configuration'], + ['docs/tool-catalog.md', 'reference/tool-catalog.md', 'Tool Schema', 'Tool schemas'], + ['docs/cordis-catalog/services.md', 'reference/cordis-catalog/services.md', '服务', 'Services'], + ['docs/cordis-catalog/events.md', 'reference/cordis-catalog/events.md', '事件', 'Events'], + ['docs/persistence-catalog.md', 'reference/persistence-catalog.md', '持久化事件', 'Persistence events'], + ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + source, + route, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '生成参考', en: 'Generated reference' }, + order, + })), + ...([ + ['core.md', '核心数据结构', 'Core data structures'], + ['scope.md', '作用域', 'Scopes'], + ['session.md', '会话', 'Sessions'], + ['system-prompt.md', '系统提示词', 'System prompts'], + ['tools.md', '工具', 'Tools'], + ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], + ['bash.md', 'Bash 执行', 'Bash execution'], + ['filesystem.md', '文件系统', 'Filesystem'], + ['code-runtime.md', '代码运行时', 'Code runtime'], + ['compaction.md', '上下文压缩', 'Compaction'], + ['subagent.md', '子代理', 'Subagents'], + ['workflow.md', '工作流', 'Workflows'], + ['skills.md', '技能', 'Skills'], + ['approval.md', '审批', 'Approvals'], + ['user-interaction.md', '用户交互', 'User interaction'], + ['sandbox.md', '沙箱', 'Sandboxing'], + ['web.md', 'Web 访问', 'Web access'], + ['persistence.md', '会话持久化', 'Session persistence'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/core-data-structures/${file}`, + route: `reference/core-data-structures/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '数据结构', en: 'Data structures' }, + order, + ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), + })), + ...([ + ['adding-a-package.md', '新增 Package', 'Adding a package'], + ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], + ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], + ['extension-cookbook.md', '扩展模式', 'Extension patterns'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/cookbook/${file}`, + route: `reference/cookbook/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '开发手册', en: 'Cookbook' }, + order, + })), +]) /** Every canonical page published by the documentation website. */ export const docsPages: DocsPage[] = [ - ...zhGuide, - ...zhDevelop, - ...enOverview, - ...enCatalogs, - ...enCore, - ...enCookbook, + ...homeAndGuide, + ...develop, + ...reference, ] From 2dc62497ceb19edfd55a8afb9c2198dd60d6a3bc Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 14 Jul 2026 20:05:57 +0800 Subject: [PATCH 008/132] =?UTF-8?q?feat(sandbox):=20cross-family=20file=20?= =?UTF-8?q?sandbox=20=E2=80=94=20one=20policy=20home,=20sandboxed=20fs=20p?= =?UTF-8?q?rovider,=20fs=20escalation=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox RFC's deferred cross-family phase. - dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the deployment default mode + workspaceRoot and the per-session override event, renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter. Decouples the bash seam from dsh-session. - dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences write/edit by the per-call mode (read-only denies, workspace-write contains to the workspace + temp roots via the shared writableRoots, danger passes through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent re-canonicalization. A policy fence in trusted code, not a kernel boundary. - dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider ladder, denial/hint markers, approveEscalation) both tool families use; approveEscalation takes a structural approver so dsh-sandbox gains no approval/agent dependency, and both tools stay duplication-free. - tool-fs: write/edit advertise sandbox_permissions/justification under a confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker, and resolve the same one-approved-wider retry. - examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating that disabled the fs stack under confined modes. RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact. --- docs/architecture.md | 1 + docs/capability-seams.md | 11 +- docs/config-catalog.md | 74 ++++-- docs/cordis-catalog/events.md | 6 +- docs/cordis-catalog/services.md | 20 +- docs/core-data-structures/filesystem.md | 3 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 49 ++-- docs/persistence-catalog.md | 28 +-- docs/rfc/INDEX.md | 1 + .../implemented/feature/2026-07-06-sandbox.md | 13 +- .../2026-07-14-cross-family-fs-sandbox.md | 92 +++++++ examples/acp-agent/composition.md | 9 +- examples/acp-agent/cordis.yml | 33 +-- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../advanced-toolchain/session.jsonl | 2 +- .../system-prompt.golden.md | 41 ++++ .../snapshots/both-mode-turn/session.jsonl | 2 +- .../both-mode-turn/system-prompt.golden.md | 41 ++++ .../code-mode-turn/system-prompt.golden.md | 41 ++++ .../escalation-approved/session.jsonl | 6 +- .../escalation-rejected/session.jsonl | 6 +- .../tests/snapshots/fs-edit/session.jsonl | 4 +- .../snapshots/fs-edit/stdout.golden.jsonl | 8 +- .../snapshots/fs-policy-reject/session.jsonl | 6 +- .../fs-policy-reject/stdout.golden.jsonl | 12 +- .../snapshots/fs-read-window/session.jsonl | 2 +- .../fs-read-window/stdout.golden.jsonl | 4 +- .../tests/snapshots/fs-read/session.jsonl | 2 +- .../snapshots/fs-read/stdout.golden.jsonl | 4 +- .../fs-write-overwrite/session.jsonl | 4 +- .../fs-write-overwrite/stdout.golden.jsonl | 8 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- .../snapshots/fs-write/stdout.golden.jsonl | 4 +- .../hook-cc-pretool-ask/session.jsonl | 4 +- .../permission-switching/session.jsonl | 8 +- .../system-prompt.golden.md | 8 +- .../tests/snapshots/skill-load/session.jsonl | 2 +- .../skill-load/system-prompt.golden.md | 6 + .../tests/snapshots/text-turn/session.jsonl | 2 +- .../text-turn/system-prompt.golden.md | 6 + .../snapshots/workspace-edit/session.jsonl | 2 +- .../workspace-edit/stdout.golden.jsonl | 4 +- packages/bash/bash-sandbox/package.json | 5 +- packages/bash/bash-sandbox/src/index.ts | 51 ++-- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 4 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 4 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 27 ++- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 4 +- packages/bash/bash-sandbox/tsconfig.json | 6 +- packages/bash/bash/package.json | 2 - packages/bash/bash/src/index.ts | 1 - packages/bash/bash/src/types.ts | 2 +- packages/bash/bash/tsconfig.json | 3 - packages/bash/tool-bash/package.json | 3 +- packages/bash/tool-bash/src/index.ts | 124 +++------- packages/bash/tool-bash/tests/tools.spec.ts | 15 +- packages/bash/tool-bash/tsconfig.json | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 9 +- packages/fs/README.md | 5 +- packages/fs/fs-sandbox/README.md | 19 ++ packages/fs/fs-sandbox/package.json | 38 +++ packages/fs/fs-sandbox/src/index.ts | 155 ++++++++++++ .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 224 ++++++++++++++++++ packages/fs/fs-sandbox/tsconfig.json | 30 +++ packages/fs/fs/package.json | 2 + packages/fs/fs/src/index.ts | 39 ++- packages/fs/fs/src/types.ts | 1 + packages/fs/fs/tsconfig.json | 3 +- packages/fs/tool-fs/package.json | 6 + packages/fs/tool-fs/src/edit.ts | 43 +++- packages/fs/tool-fs/src/index.ts | 12 +- packages/fs/tool-fs/src/sandbox.ts | 132 +++++++++++ packages/fs/tool-fs/src/write.ts | 32 ++- packages/fs/tool-fs/tests/tools.spec.ts | 162 +++++++++++++ packages/fs/tool-fs/tsconfig.json | 5 +- packages/sandbox/README.md | 7 +- packages/sandbox/sandbox-policy/README.md | 23 ++ packages/sandbox/sandbox-policy/package.json | 37 +++ packages/sandbox/sandbox-policy/src/index.ts | 84 +++++++ .../sandbox-policy}/src/session-mode.ts | 47 ++-- .../sandbox-policy/tests/policy.spec.ts | 67 ++++++ packages/sandbox/sandbox-policy/tsconfig.json | 27 +++ packages/sandbox/sandbox/src/escalation.ts | 189 +++++++++++++++ packages/sandbox/sandbox/src/index.ts | 11 + packages/sandbox/sandbox/src/roots.ts | 51 ++++ .../sandbox/sandbox/tests/escalation.spec.ts | 111 +++++++++ packages/sandbox/sandbox/tests/roots.spec.ts | 39 +++ packages/ui/acp/tests/config-options.spec.ts | 10 +- packages/ui/permission/package.json | 2 + packages/ui/permission/src/index.ts | 11 +- .../ui/permission/tests/permission.spec.ts | 10 +- packages/ui/permission/tsconfig.json | 3 + pnpm-lock.yaml | 109 ++++++--- python/sdk-runtime/package.json | 1 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-doc-graphs.ts | 13 +- tsconfig.build.json | 2 + tsconfig.json | 2 + 100 files changed, 2238 insertions(+), 385 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md create mode 100644 packages/fs/fs-sandbox/README.md create mode 100644 packages/fs/fs-sandbox/package.json create mode 100644 packages/fs/fs-sandbox/src/index.ts create mode 100644 packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts create mode 100644 packages/fs/fs-sandbox/tsconfig.json create mode 100644 packages/fs/tool-fs/src/sandbox.ts create mode 100644 packages/sandbox/sandbox-policy/README.md create mode 100644 packages/sandbox/sandbox-policy/package.json create mode 100644 packages/sandbox/sandbox-policy/src/index.ts rename packages/{bash/bash => sandbox/sandbox-policy}/src/session-mode.ts (52%) create mode 100644 packages/sandbox/sandbox-policy/tests/policy.spec.ts create mode 100644 packages/sandbox/sandbox-policy/tsconfig.json create mode 100644 packages/sandbox/sandbox/src/escalation.ts create mode 100644 packages/sandbox/sandbox/src/roots.ts create mode 100644 packages/sandbox/sandbox/tests/escalation.spec.ts create mode 100644 packages/sandbox/sandbox/tests/roots.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 8dc7f6f4b1..7483caf040 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,6 +26,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | +| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy: mode, workspace root, per-session override | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index fb4746dc68..c683b59283 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -56,6 +56,8 @@ flowchart LR pkg_sandbox["sandbox"] svc_sandbox["ctx.sandbox
Process-sandbox seam"] pkg_sandbox_local["sandbox-local"] + svc_sandboxPolicy["ctx.sandboxPolicy
Sandbox policy home"] + pkg_fs_sandbox["fs-sandbox"] pkg_approval["approval"] svc_approval["ctx.approval
Approval seam"] pkg_permission["permission"] @@ -99,12 +101,14 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_fs_sandbox --> svc_fs pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm pkg_permission --> svc_permission pkg_sandbox --> svc_sandbox + pkg_sandbox --> svc_sandboxPolicy pkg_sandbox_local --> svc_sandbox pkg_session --> svc_sessions pkg_session_persistence --> svc_sessionPersistence @@ -147,6 +151,10 @@ flowchart LR svc_llm --> pkg_compact_basic svc_permission --> pkg_acp svc_sandbox --> pkg_bash_sandbox + svc_sandboxPolicy --> pkg_bash_sandbox + svc_sandboxPolicy --> pkg_fs_sandbox + svc_sandboxPolicy --> pkg_tool_bash + svc_sandboxPolicy --> pkg_tool_fs svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_session_query @@ -194,10 +202,11 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | +| `ctx.sandboxPolicy` | `core` | [`sandbox`](../packages/sandbox/sandbox) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`tool-bash`](../packages/bash/tool-bash), [`tool-fs`](../packages/fs/tool-fs) | - | The one home for the deployment default mode + workspace root and the per-session `sandbox/mode` override; both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | -| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | +| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4492d413d8..c5e9346af6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -153,28 +153,21 @@ Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `sandbox` +Requires: `sandbox` · `sandboxPolicy` ```ts config-catalog /** - * Plugin config: the local executor's knobs plus the sandbox policy. All - * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the - * fail-safe default; an example that wants a workspace-writable agent opts in - * explicitly). The runner choice is NOT configured here: which platform - * backend confines the command is the `ctx.sandbox` provider's config. + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and the `workspace-write` boundary root — is NOT here: it + * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one + * home both enforcing families read, so bash and fs can never confine to + * different roots. The runner choice is likewise the `ctx.sandbox` provider's + * config, not this executor's. */ -export interface Config extends LocalConfig { - /** File-sandbox mode commands run under (default: `read-only`). */ - mode?: SandboxMode - /** - * Root directory `workspace-write` mode may write under (default: the - * executor's default working directory — `cwd`, else `process.cwd()`). - */ - workspaceRoot?: string -} +export type Config = LocalConfig ``` -Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md) +Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) Source: [`packages/bash/bash-sandbox/src/index.ts:60`](../packages/bash/bash-sandbox/src/index.ts) @@ -267,6 +260,24 @@ export interface Config { Source: [`packages/fs/fs-local/src/index.ts:58`](../packages/fs/fs-local/src/index.ts) +## `@deepseek-ai/dsh-fs-sandbox` + +Requires: `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve + * base for relative paths). The sandbox default (mode + `workspace-write` + * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home + * both enforcing families share. + */ +export type Config = LocalConfig +``` + +Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local) + +Source: [`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-hooks-claude` Requires: `bash` @@ -464,7 +475,7 @@ export interface Config { * runs under while the preset is active — plus its presentation. */ export interface PresetSpec { - /** The `bash/sandbox-mode` value the preset writes through. */ + /** The `sandbox/mode` value the preset writes through. */ sandbox: SandboxMode /** The `approval/policy` value the preset writes through. */ approval: ApprovalPolicy @@ -477,7 +488,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:97`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:100`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -558,6 +569,31 @@ export interface Config { Source: [`packages/sandbox/sandbox-local/src/index.ts:36`](../packages/sandbox/sandbox-local/src/index.ts) +## `@deepseek-ai/dsh-sandbox-policy` + +```ts config-catalog +/** + * Plugin config: the deployment's sandbox default. All optional — `Config` + * supplies the defaults (`mode: 'read-only'` is the fail-safe default; a + * deployment that wants a workspace-writable agent opts in explicitly). The + * runner choice is NOT here (it is the `ctx.sandbox` provider's config), nor + * is any per-family knob: this is the one shared policy home. + */ +export interface Config { + /** File-sandbox mode a session starts from (default: `read-only`). */ + mode?: SandboxMode + /** + * Absolute root directory `workspace-write` may write under (default: + * `process.cwd()`). Both enforcing families fence against this SAME root. + */ + workspaceRoot?: string +} +``` + +Depends on: [`SandboxMode`](core-data-structures/sandbox.md) + +Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` Requires: `sessions` @@ -900,7 +936,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:48`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:52`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index acbd39b8fa..2600fe9cfc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -201,7 +201,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:124`](../../packages/fs/fs/src/index.ts) ### `fs/observed` — emit @@ -213,7 +213,7 @@ Record that an actor observed a target at a version, after a successful read/wri Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:139`](../../packages/fs/fs/src/index.ts) ### `fs/write-intent` — waterfall @@ -225,7 +225,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:110`](../../packages/fs/fs/src/index.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c399cf83d4..3d0087a129 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -79,7 +79,7 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:62`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:61`](../../packages/bash/bash/src/index.ts) ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam) @@ -139,13 +139,13 @@ abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> abstract listDir(target: FsTarget, signal?: AbortSignal): Promise -abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise -abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise +abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise +abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) · [SandboxMode](../core-data-structures/sandbox.md) -Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:173`](../../packages/fs/fs/src/index.ts) ## `ctx.llm` — `LlmService` @@ -174,7 +174,7 @@ set(session: Session, name: string): void Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:115`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:118`](../../packages/ui/permission/src/index.ts) ## `ctx.sandbox` — `SandboxProvider` (abstract seam) @@ -192,7 +192,13 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:180`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:191`](../../packages/sandbox/sandbox/src/index.ts) + +## `ctx.sandboxPolicy` — `SandboxPolicyService` + +The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode and workspace root; enforcing implementations read defaultMode and workspaceRoot, and the tool layers fold each session's `sandbox/mode` override with effectiveSandboxMode on top. + +Source: [`packages/sandbox/sandbox-policy/src/index.ts:60`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index bbc55be966..80e3fa8e95 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -132,6 +132,7 @@ type FsErrorCode = | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' | 'FS_PERMISSION_DENIED' + | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' @@ -140,7 +141,7 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. ## The service and the plugin diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b0a82673e..c084facc40 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,9 +21,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:124`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:139`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:110`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 7d6bf65c21..eed8b99f41 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -35,6 +35,7 @@ flowchart TD pkg_fs["fs"] pkg_fs_local["fs-local"] pkg_fs_policy["fs-policy"] + pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] end subgraph group_skill["packages/skill"] @@ -113,6 +114,7 @@ flowchart TD subgraph group_sandbox["packages/sandbox"] pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] + pkg_sandbox_policy["sandbox-policy"] end subgraph group_workflow["packages/workflow"] pkg_tool_workflow["tool-workflow"] @@ -128,8 +130,6 @@ flowchart TD pkg_session --> pkg_scope pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope - pkg_fs --> pkg_brand - pkg_fs --> pkg_llm pkg_web --> pkg_llm pkg_sandbox --> pkg_llm pkg_agent --> pkg_brand @@ -139,11 +139,9 @@ flowchart TD pkg_agent --> pkg_system_prompt pkg_bash --> pkg_brand pkg_bash --> pkg_sandbox - pkg_bash --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_policy --> pkg_fs - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_skill + pkg_fs --> pkg_brand + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_compact --> pkg_llm pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_timeout @@ -156,8 +154,14 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_policy --> pkg_fs + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_skill pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact pkg_compact_basic --> pkg_llm @@ -196,8 +200,14 @@ flowchart TD pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_permission --> pkg_bash pkg_permission --> pkg_sandbox + pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval pkg_agent_loop --> pkg_agent @@ -209,16 +219,19 @@ flowchart TD pkg_agent_loop --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_llm pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm + pkg_tool_fs --> pkg_sandbox + pkg_tool_fs --> pkg_sandbox_policy pkg_tool_fs --> pkg_session pkg_tool_fs --> pkg_system_prompt pkg_tool_fs --> pkg_tools + pkg_tool_fs --> pkg_user_approval pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill @@ -350,14 +363,11 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | @@ -366,7 +376,11 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -377,11 +391,12 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index afa07bb89d..53ffa04792 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -71,18 +71,6 @@ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-st Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts) -### `bash/*` - -#### `bash/sandbox-mode` — log-only - -The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user; see the tool layer's narrator). - -```ts persistence-catalog -'bash/sandbox-mode': { mode: SandboxMode } -``` - -Source: [`packages/bash/bash/src/session-mode.ts:31`](../packages/bash/bash/src/session-mode.ts) - ### `compact/*` #### `compact/end` — log-only @@ -157,13 +145,13 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook- #### `permission/preset` — log-only -The session's permission preset was switched — log-only (the `bash/sandbox-mode` precedent): durable and replayable, never in the model transcript. The LAST such event is the session's preset (effectivePermissionPreset); the knob events the switch wrote through follow it in the same turn, and they — not this record of the user's choice — are what execution reads. +The session's permission preset was switched — log-only (the `sandbox/mode` precedent): durable and replayable, never in the model transcript. The LAST such event is the session's preset (effectivePermissionPreset); the knob events the switch wrote through follow it in the same turn, and they — not this record of the user's choice — are what execution reads. ```ts persistence-catalog 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:42`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:45`](../packages/ui/permission/src/index.ts) ### `prompt/*` @@ -201,6 +189,18 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +### `sandbox/*` + +#### `sandbox/mode` — log-only + +The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user; see the tool layer's narrator). + +```ts persistence-catalog +'sandbox/mode': { mode: SandboxMode } +``` + +Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/sandbox/sandbox-policy/src/session-mode.ts) + ### `steering/*` #### `steering/message` — surface diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 54e59f7aa1..5bd4d46676 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -78,6 +78,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | +| [Cross-family file sandbox — one policy home, a sandboxed fs provider, and fs escalation parity](implemented/feature/2026-07-14-cross-family-fs-sandbox.md) | 2026-07-14 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index fa10a783a2..4f86ad35a4 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -115,7 +115,7 @@ The default is composition config (`cordis.yml`) — operator-owned, process-wid ```ts interface SessionEventMap { - 'bash/sandbox-mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' } + 'sandbox/mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' } 'approval/policy': { policy: 'ask' | 'never' } } ``` @@ -130,9 +130,7 @@ Each owner exports the same three-piece kit: the event declaration, a pure fold #### In-process tools -fs/web/todo execute in-process, so their sandbox semantics are policy at their seams: the fs intent gates deciding by the shared mode vocabulary (§ Deferred phases, cross-family) make `read-only` a real boundary instead of a bash-only approximation — until then the contract says so honestly. No generic per-tool sandbox runtime: a host-mediated tool leaves the process only by returning declarative effects the host validates, which is a rewrite, not a wrapper. - -FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam. +fs/web/todo execute in-process, so their sandbox semantics are policy at their seams. The fs seam now enforces the shared mode vocabulary through a sandboxed provider (`dsh-fs-sandbox` fences write/edit by mode; see [the cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)), so `read-only`/`workspace-write` are real boundaries for the filesystem tools, not a bash-only approximation. web/todo remain unfenced (web's only effect is network, outside the file-effect mode vocabulary). No generic per-tool sandbox runtime: a host-mediated tool leaves the process only by returning declarative effects the host validates, which is a rewrite, not a wrapper — the follow-up settled on one shared policy home (`ctx.sandboxPolicy`) with per-seam enforcement, not a uniform wrapper. ### Testing @@ -145,8 +143,7 @@ FIXME: Revisit this tool-local boundary. The follow-up design needs to determine Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. -- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. -- **Cross-family boundary** — the fs intent gates decide by the shared mode, making `read-only`/`workspace-write` real boundaries beyond bash. +- **Per-session workspace root** — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed. Centralizing the root on `ctx.sandboxPolicy` (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)) is the groundwork. - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). - **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. @@ -190,7 +187,7 @@ What shipped pins — the tiers in Testing hold each: Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. -- **`read-only` is not yet a cross-family boundary.** Until the fs intent gates decide by the shared mode, the claim holds for bash only; the contract says so honestly (§ In-process tools). +- **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). - **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown `SANDBOX_UNAVAILABLE`, the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. @@ -212,7 +209,7 @@ Behavioral and usage questions only — every "why not X?" design question lives - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam. -- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an `execve` wrapper is mechanically meaningless; their `read-only` semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly. +- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist, or cover background tasks?** Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via `bash_output` is left open in § Escalation. - **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next turn's `agent/prompt-submit`, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told — its next command simply behaves under the new mode. - **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution). diff --git a/docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md new file mode 100644 index 0000000000..cfac649368 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -0,0 +1,92 @@ +# RFC: Cross-family file sandbox — one policy home, a sandboxed fs provider, and fs escalation parity + +Status: implemented + +## Problem + +`SandboxMode` claims file effects, but originally only `ctx.bash` enforced it. The fs tools (`write`/`edit`) mutate the host filesystem in-process through `ctx.fs`, where an OS argv wrapper is mechanically meaningless — [the sandbox RFC](2026-07-06-sandbox.md) § In-process tools records this and left cross-family enforcement as a deferred phase with an open question: whether in-process enforcement stays per-seam or becomes a uniform harness capability. This RFC is that phase, and answers it: one shared policy home, per-seam enforcement at each family's correct altitude. + +The gap was not read-only-shaped. A confined coding agent's product mode is `workspace-write`: bash may already write under the workspace root while everything outside is denied, so an fs enforcement that could only deny-all would be strictly worse than disabling the fs tools — the model would attempt an in-workspace `write`, be denied, and learn to detour through `bash` heredocs. Cross-family enforcement therefore speaks the full mode ladder, including the path-containment judgment `workspace-write` requires (canonical targets; `..`/symlink/absolute-path escapes) and the same escalation lever bash carries. + +A second enforcing family also exposed an ownership problem in the original layout. The deployment default (`mode` + `workspaceRoot`) was configured on `dsh-bash-sandbox`, and the per-session override event was `bash/sandbox-mode`, folded and written by `dsh-bash`'s session-mode kit. With fs enforcing the same policy, either fs reads bash's config and events (a capability family depending on a sibling's plugin config) or each family carries its own copy — and two copies of `workspaceRoot` drift into exactly the split world the sandbox RFC warns about: bash confined to one root while fs fences another. + +## Decision + +Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching `agent-loop`. + +### `ctx.sandboxPolicy` — one home for mode and workspace root + +`packages/sandbox/sandbox-policy/` (`@deepseek-ai/dsh-sandbox-policy`) registers `ctx.sandboxPolicy`, the single owner of the deployment's sandbox policy: + +- `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load. +- The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent. +- `defaultMode` / `workspaceRoot` accessors the enforcing implementations read for their resolve fallback and boundary. + +`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and reads the default from it; its `resolve()` precedence is unchanged (escalation grant > per-call stamp > default). `dsh-tool-bash` and `dsh-tool-fs` fold the session's `sandbox/mode` with `effectiveSandboxMode` to stamp each call; `dsh-permission` presets and the ACP bridge write through the relocated setter. The seam that owns bash execution no longer depends on `dsh-session` at all — the session dependency moved to the policy package with the fold. + +### `dsh-fs-sandbox` — enforcement inside the provider + +`packages/fs/fs-sandbox/` (`@deepseek-ai/dsh-fs-sandbox`) mirrors the `bash-local`/`bash-sandbox` split: `SandboxedFileSystem extends LocalFileSystem`, registered as `ctx.fs`, injecting `sandboxPolicy`. Reads (`resolve`/`stat`/`readText`/`streamText`/`listDir`) pass through untouched — every mode permits reading. The two mutations enforce by mode before delegating to the inherited atomic write: + +- `read-only` denies `writeText`/`editText` outright. +- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Containment is prefix-inclusion on real paths; the target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. +- `danger-full-access` delegates unfenced. + +A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth. + +The threat model is stated in the package README: a policy fence in trusted code over model-controlled paths, not a kernel boundary — the operations are the seam's own, only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface (the `code-runtime` "containment, not a security boundary" precedent). Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job. The residual resolve-to-syscall race is narrowed by the in-place re-canonicalization and eliminated only by platform primitives (`openat2` `RESOLVE_BENEATH`) not worth their portability cost here. + +### Tool parity — one denial marker, one escalation flow + +`dsh-tool-fs` stamps the effective mode onto each mutation and maps `FS_SANDBOX_DENIED` to the marker the model already knows from bash: `[sandbox: file access denied under mode]`. When `ctx.fs.sandboxMode` reports a confining mode at registration, `write` and `edit` advertise the same `sandbox_permissions` + `justification` fields, teach the same same-turn retry, and resolve the same `ctx.approval` request before executing — the four outcomes and their verbatim fail-closed texts carried over from [the sandbox RFC](2026-07-06-sandbox.md) § Escalation (strict widening checked at execution against the call's effective mode; a grant consumed by the one call that asked; no new session events). + +The shared pieces live in `dsh-sandbox`, which owns the mode types: `WIDER_MODES`, the escalation-target enum, the argument-pairing validation, the denial/hint marker builders, and `approveEscalation` — the ordered fail-closed choreography. `approveEscalation` takes a minimal STRUCTURAL ask-function (`EscalationChannel`), not the approval service type, so `dsh-sandbox` gains no dependency on the approval or agent packages: each tool closes over its own `ctx.approval.request(...)`, agent, call id, and tool name and hands the closure down. `dsh-tool-bash` and `dsh-tool-fs` both use these; the cross-file duplication gate holds the single-sourcing honest. + +The [`examples/acp-agent`](../../../../examples/acp-agent/cordis.yml) composition loads `dsh-sandbox-policy` and `dsh-fs-sandbox`, moves the `mode`/`workspaceRoot` config to the policy entry, and drops the old gating that disabled the fs stack under confined modes; `fs-policy` (read-before-edit) composes orthogonally on top. The system prompt still states no sandbox mode — the marker teaches the boundary at the moment it matters, per the sandbox RFC's live evidence. + +### The enforcement point: provider, not intent gate + +The sandbox RFC's original cross-family sketch put fs enforcement on the `fs/write-intent`/`fs/edit-intent` events. This RFC enforces in the provider instead, on two mechanical facts: the intent slots are single-decision first-wins (occupied by `dsh-fs-policy`, whose contract names a second decider a misconfiguration), and the intent events are dispatched only by `dsh-tool-fs` — a direct `ctx.fs` caller (a cordis-mounted plugin, a custom tool) bypasses them, where provider-level enforcement covers every caller by construction. The sandbox RFC's deferred-phase wording is updated to match in the same change. + +### Out of scope + +- **Network policy for `ctx.web`** — `SandboxMode` claims file effects only; a web-only network knob while bash `curl` runs free would be a false boundary. Revisit when a bash backend enforces network (bwrap `--unshare-net`, Landlock ABI v4+). +- **The `subagent-acp` consumer** and **per-session workspace root** — unchanged deferred phases of the sandbox RFC; centralizing the root in `ctx.sandboxPolicy` is groundwork for the latter, not its design. +- **A uniform per-tool sandbox runtime** — remains rejected for the reasons in the sandbox RFC. + +## Alternatives considered + +- **Enforce on the `fs/*` intent events (the sandbox RFC's original sketch)** — rejected on the two mechanical facts in § The enforcement point: single-slot first-wins already occupied, and a bypass for direct `ctx.fs` callers. Provider-level enforcement covers every caller and mirrors bash's swap-the-implementation shape. +- **Enforce in `tools/pre-execute`** — rejected: the listener sees the model's raw path string before `resolve()`, so it would re-implement cwd defaulting and symlink canonicalization and still race the real resolve. Disqualifying for `workspace-write`, a judgment over canonical paths. +- **Inline checks in `dsh-tool-fs`** — rejected: covers only the tool path (same bypass as the intent events) and duplicates resolve knowledge one layer above where the canonical target already exists. +- **A `mode` flag on `dsh-fs-local` instead of a sibling backend** — rejected: the capability fact must be composition truth the way `dsh-bash-local` vs `dsh-bash-sandbox` is; a config flag makes the tool's advertisement conditional on configuration, and the bash family already establishes the sibling-package shape. +- **Kernel-enforced fs mutations via a confined helper subprocess** — rejected: a process per write; `editText`'s read-match-write critical section would have to move wholesale into the child to stay atomic; and the threat surface (trusted operations, untrusted path argument) does not need a kernel — the fence in trusted code is the complete answer, while untrusted-code isolation stays on `ctx.bash`. +- **Per-family policy config with a load-time consistency check** — rejected: two homes for one fact, patched by a check that must enumerate every future enforcing family; the policy service makes drift inexpressible instead of detected. +- **Keep the override event in `dsh-bash` as `bash/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims. +- **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural ask-function keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them. +- **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it churns every `writeText`/`editText` caller and splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `sandboxMode` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. +- **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open. + +## Consequences + +What shipped — the tiers in § Testing hold each: + +- Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`. +- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, and a new file created under such a symlink — denies every escape on real disks. +- A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. +- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. +- A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. +- The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`. +- `agent-loop` is untouched — everything rides `ctx.sandboxPolicy`, the `ctx.fs` seam, `SessionEventMap` merging, and the tool-execution pipeline. + +Costs and accepted limits: + +- **The fs fence is a policy boundary, not a kernel one.** Its threat surface is model-chosen paths, not adversarial host processes; the residual resolve-to-syscall TOCTOU is narrowed, not eliminated, and the README says so. Kernel boundaries remain bash's. +- **`dsh-bash-sandbox` gains a hard dependency on `ctx.sandboxPolicy`.** Every sandboxed composition adds one `cordis.yml` entry or fails loud at load — the intended pre-release foundation move; the examples update in the same change. +- **Fence-vs-runner parity is derived, not asserted.** The fs fence and the Seatbelt profile both take their writable set from `writableRoots`, and a parity unit test pins the sets; a runner profile changing its writable set without that function would drift. +- **The marker and escalation teaching now serve two families.** A wording change is a coordinated edit behind one builder in `dsh-sandbox`; the duplication gate and pinned snapshots hold it single-sourced, at the cost that fs and bash cannot deliberately diverge in phrasing without splitting the builder. + +## Testing + +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit. +- Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index d5c3096666..f7ec986a9d 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_acp_llm_deepseek plugin_acp_sandbox["sandbox
@deepseek-ai/dsh-sandbox-local"] cfg --> plugin_acp_sandbox + plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] + cfg --> plugin_acp_sandbox_policy plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -45,8 +47,8 @@ flowchart LR cfg --> plugin_acp_tool_todo plugin_acp_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] cfg --> plugin_acp_repeat_tool_guard - plugin_acp_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_acp_fs_local + plugin_acp_fs_sandbox["fs-sandbox
@deepseek-ai/dsh-fs-sandbox"] + cfg --> plugin_acp_fs_sandbox plugin_acp_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] cfg --> plugin_acp_fs_policy plugin_acp_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] @@ -61,6 +63,7 @@ flowchart LR | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | +| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | @@ -74,7 +77,7 @@ flowchart LR | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | | `hooks-claude` | `@deepseek-ai/dsh-hooks-claude` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 42c8bb46f0..e752dcc710 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -23,19 +23,25 @@ - deepseek-v4-flash - deepseek-v4-pro -# The default composition confines bash to the workspace and asks before a -# wider retry. Snapshot runs select danger-full-access so the established -# scenarios remain runner-independent; DSH_PERMISSION_MODE provides the same -# explicit deployment/test override outside the snapshot harness. +# The default composition confines bash AND the filesystem tools to the +# workspace and asks before a wider retry. Snapshot runs select +# danger-full-access so the established scenarios remain runner-independent; +# DSH_PERMISSION_MODE provides the same explicit deployment/test override +# outside the snapshot harness. The sandbox mode + workspace root live on +# ctx.sandboxPolicy — the one home both enforcing families (bash, fs) read. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" + workspaceRoot: !!js process.cwd() + - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: timeoutMs: 60000 - mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" - workspaceRoot: !!js process.cwd() - id: approval name: '@deepseek-ai/dsh-user-approval' @@ -119,22 +125,21 @@ - id: repeat-tool-guard name: '@deepseek-ai/dsh-repeat-tool-guard' -# Filesystem tools do not ride the bash sandbox, so the confined default omits -# them. Snapshot tests and explicit danger-full-access launches keep the -# established filesystem scenarios by enabling the whole stack together. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" +# The filesystem stack rides the SAME sandbox policy as bash: dsh-fs-sandbox +# replaces dsh-fs-local behind ctx.fs and fences write/edit by the effective +# mode (read-only denies, workspace-write contains to the workspace + temp +# roots, danger-full-access passes through), so read/write/edit are available +# under every mode. fs-policy (read-before-edit) composes orthogonally on top. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' config: cwd: !!js process.cwd() - id: fs-policy name: '@deepseek-ai/dsh-fs-policy' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" # The Claude Code hook bridge. `configPath` is PROCESS-LEVEL: it is read ONCE at # load and the relative `./hooks.json` resolves against the ACP server's launch diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index cdc09c92c0..735f6fe05f 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 79d9b94ad9..dab6ace37c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 7155b1c2a6..6beb7cb53e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 6e8f157ace..94cba74575 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -67,6 +73,30 @@ declare const tools: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -121,5 +151,16 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 66cdecedfb..60297e92d6 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 98b4f97fee..5f71fbdd76 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -52,6 +58,30 @@ declare const tools: { /** Task id returned by the bash tool. */ task_id: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -106,5 +136,16 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 98b4f97fee..5f71fbdd76 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -52,6 +58,30 @@ declare const tools: { /** Task id returned by the bash tool. */ task_id: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -106,5 +136,16 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 7dff360513..743ce40663 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962245380,"data":{"preset":"workspace-write"}} -{"type":"bash/sandbox-mode","seq":2,"time":1783962245380,"data":{"mode":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784023679499,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962245380,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962245380,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962245382,"data":{"turn":1,"step":1}} @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"d409f075-74f1-4637-9e13-6e80d7b6f6ff","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"d409f075-74f1-4637-9e13-6e80d7b6f6ff","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"0dfc6fba-ccdf-4233-8b48-87003f0b75b7","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"0dfc6fba-ccdf-4233-8b48-87003f0b75b7","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 55cad3689d..56380e66c3 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962246267,"data":{"preset":"workspace-write"}} -{"type":"bash/sandbox-mode","seq":2,"time":1783962246267,"data":{"mode":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784023680100,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962246267,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962246267,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962246269,"data":{"turn":1,"step":1}} @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"e592115c-e915-4010-9bf5-cc5e7bb6f8bc","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"e592115c-e915-4010-9bf5-cc5e7bb6f8bc","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"5b59a78f-4b4c-400b-b7f7-d8f689f75854","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"5b59a78f-4b4c-400b-b7f7-d8f689f75854","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 723282d1a8..90f009946d 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -69,7 +69,7 @@ {"type":"assistant/chunk","seq":67,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":68,"time":1783352086059,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67],"surfaceOp":"append"} {"type":"tool/call","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"/tmp/acp-snap-cwd-0BxHdV/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","seq":71,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":72,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":73,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -129,7 +129,7 @@ {"type":"assistant/chunk","seq":127,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[129],"surfaceOp":"append"} +{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":131,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":132,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":133,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index 5bc88537ce..c7eda2cfd0 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -46,8 +46,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} @@ -66,8 +66,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt","old_string":"DEBUG","new_string":"RELEASE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"Edit config.txt","kind":"edit","status":"in_progress","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"completed","content":[{"type":"diff","path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}],"title":"Edit config.txt"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Done"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 8a500475c0..802120fd9c 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -77,7 +77,7 @@ {"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":76,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} {"type":"tool/call","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","seq":79,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":80,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":81,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -144,7 +144,7 @@ {"type":"assistant/chunk","seq":142,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":143,"time":1783611705573,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} {"type":"tool/call","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[144],"surfaceOp":"append"} +{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","seq":146,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":147,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":148,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -225,7 +225,7 @@ {"type":"assistant/chunk","seq":223,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":224,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223],"surfaceOp":"append"} {"type":"tool/call","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[225],"surfaceOp":"append"} +{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[225],"surfaceOp":"append"} {"type":"step/end","seq":227,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":228,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":229,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 864cc3de9f..fd465755bf 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -36,8 +36,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} @@ -82,8 +82,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","title":"Read settings.txt","kind":"read","status":"in_progress","locations":[{"path":"settings.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} @@ -124,8 +124,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" work"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"completed","content":[{"type":"diff","path":"settings.txt","oldText":"color: blue","newText":"color: green"}],"title":"Edit settings.txt"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replacement"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index f22aba96b2..becc503c65 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -91,7 +91,7 @@ {"type":"assistant/chunk","seq":89,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":90,"time":1783352101348,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89],"surfaceOp":"append"} {"type":"tool/call","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[91],"surfaceOp":"append"} +{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"/tmp/acp-snap-cwd-N9HCkt/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false},"sourceEventSeqs":[91],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":95,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index c283736334..67c1a6ba08 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -56,8 +56,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"big.txt","offset":5,"limit":4}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","title":"Read big.txt (5 - 8)","kind":"read","status":"in_progress","locations":[{"path":"big.txt","line":5}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index d91d10d39a..3af4b2ac61 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1783352073708,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"/tmp/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":56,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":57,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 4f2973bcc5..269d184534 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -29,8 +29,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 0233abbe39..47627ae7a5 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -65,7 +65,7 @@ {"type":"assistant/chunk","seq":63,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":64,"time":1783352093617,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":68,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":69,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -114,7 +114,7 @@ {"type":"assistant/chunk","seq":112,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"surfaceOp":"append"} {"type":"tool/call","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[114],"surfaceOp":"append"} +{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","seq":116,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":117,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":118,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 03c77cae98..1b85301ee4 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -42,8 +42,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} @@ -61,8 +61,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt","content":"replaced"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 6170af99a5..7e4b2dda01 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -62,7 +62,7 @@ {"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":61,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":66,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 1e68a6b90a..9d1b9744e4 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -30,8 +30,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"notes.txt","content":"hello world"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}],"title":"Write notes.txt"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 3933a939ba..43b3369b77 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"cdd11a3a-c721-4d08-8255-732218775c33","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"cdd11a3a-c721-4d08-8255-732218775c33","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"f8f9d54e-a313-4719-9950-713e317b29b7","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"f8f9d54e-a313-4719-9950-713e317b29b7","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index e1438965b7..8f6d461cff 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -1,11 +1,11 @@ {"type":"session","version":0,"id":"df041acb-2f14-4d5f-b6e2-2fb6b9eb6427","createdAt":1783860666204,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-4oJKT4"} {"type":"turn/start","seq":0,"time":1783860666206,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962244578,"data":{"preset":"workspace-write"}} -{"type":"bash/sandbox-mode","seq":2,"time":1783962244578,"data":{"mode":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784023678825,"data":{"mode":"workspace-write"}} {"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} @@ -102,12 +102,12 @@ {"type":"turn/end","seq":100,"time":1783962244601,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"turn/start","seq":101,"time":1783962244623,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":102,"time":1783962244624,"data":{"preset":"danger-full-access"}} -{"type":"bash/sandbox-mode","seq":103,"time":1783962244624,"data":{"mode":"danger-full-access"}} +{"type":"sandbox/mode","seq":103,"time":1784023678940,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":104,"time":1783962244624,"data":{"policy":"never"}} {"type":"user/message","seq":105,"time":1783962244624,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":106,"time":1783962244624,"data":{"content":[{"type":"text","text":"The approval policy changed from \"ask\" to \"never\" (changed by the user)."}],"source":{"kind":"plugin","plugin":"user-approval"}},"surfaceOp":"append"} {"type":"step/start","seq":107,"time":1783962244624,"data":{"turn":2,"step":1}} -{"type":"request/header-delta","seq":108,"time":1783962244624,"data":{"system":{"keepStart":9,"keepEnd":2,"insert":["{{system}}","{{system}}"]}}} +{"type":"request/header-delta","seq":108,"time":1783962244624,"data":{"system":{"keepStart":15,"keepEnd":2,"insert":["{{system}}","{{system}}"]}}} {"type":"assistant/chunk","seq":109,"time":1783860671025,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":110,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":111,"time":1783860671026,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md index 3da863d3b8..f20a32d382 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md @@ -5,13 +5,19 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 1c85ee81ba..fb29d33cd0 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md index d0ce1272d0..6bb634b339 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index c66f5676cc..9ad8d8506c 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md index d0ce1272d0..6bb634b339 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 04e3c3b59e..9a908f24a3 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -79,7 +79,7 @@ {"type":"assistant/chunk","seq":77,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":78,"time":1783352265491,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"surfaceOp":"append"} {"type":"tool/call","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"/tmp/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","seq":81,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":82,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":83,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index d3fd50b416..8f139d7949 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -55,8 +55,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 0077511946..73b2b88ac7 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -25,16 +25,15 @@ "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-bash-local": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "cordis": "^4.0.0-rc.6" }, - "dependencies": { - "schemastery": "^3.18.0" - }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", "cordis": "^4.0.0-rc.6" } diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 090d06b2fe..ad49a60a84 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -41,31 +41,23 @@ * @module @deepseek-ai/dsh-bash-sandbox */ -import { resolve } from 'node:path' import { Context } from 'cordis' -import z from 'schemastery' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' /** - * Plugin config: the local executor's knobs plus the sandbox policy. All - * optional — `static Config` supplies the defaults (`mode: 'read-only'` is the - * fail-safe default; an example that wants a workspace-writable agent opts in - * explicitly). The runner choice is NOT configured here: which platform - * backend confines the command is the `ctx.sandbox` provider's config. + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and the `workspace-write` boundary root — is NOT here: it + * lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one + * home both enforcing families read, so bash and fs can never confine to + * different roots. The runner choice is likewise the `ctx.sandbox` provider's + * config, not this executor's. */ -export interface Config extends LocalConfig { - /** File-sandbox mode commands run under (default: `read-only`). */ - mode?: SandboxMode - /** - * Root directory `workspace-write` mode may write under (default: the - * executor's default working directory — `cwd`, else `process.cwd()`). - */ - workspaceRoot?: string -} +export type Config = LocalConfig /** * Quote one string as a single-quoted POSIX shell word (embedded single @@ -141,24 +133,18 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r * INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is * the whole swap — the tool layer is untouched). Its configured mode is the * fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's - * durable `bash/sandbox-mode` override and stamps the effective mode onto each + * durable `sandbox/mode` override and stamps the effective mode onto each * request, while an approved escalation may stamp a strictly wider mode for * one call. The tool's per-agent prompt section states that same effective * mode, and each run's `result.sandbox` reports what actually executed plus * enforcement completeness. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox'] + static inject = ['sandbox', 'sandboxPolicy'] - // The sandbox-specific fields intersect the local executor's Config as an - // inline schema call: the config catalog walks `static Config` statically. - static override Config: z = z.intersect([ - LocalBashExecutor.Config, - z.object({ - mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'), - workspaceRoot: z.string(), - }), - ]) + // No own Config: the sandbox default (mode + workspaceRoot) moved to + // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config + // verbatim (the config catalog walks the inherited static). private readonly mode: SandboxMode private readonly workspaceRoot: string @@ -182,12 +168,11 @@ export class SandboxBashExecutor extends LocalBashExecutor { constructor(ctx: Context, config: Config) { super(ctx, config) - // schemastery (static Config) already filled the defaulted fields — the - // cast records that runtime fact (mirrors LocalBashExecutor's config - // cast). `workspaceRoot` and `cwd` have NO schema default, so their - // fallback chain is real branching. - this.mode = config.mode as SandboxMode - this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd()) + // The sandbox default (mode + workspaceRoot) is the one shared policy home + // both enforcing families read; injecting sandboxPolicy guarantees it is + // constructed first. workspaceRoot arrives already resolved absolute. + this.mode = ctx.sandboxPolicy.defaultMode + this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot } /** The configured default mode — the capability fact the tool layer reads. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 6a748bc389..ced6dcea5a 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** @@ -46,7 +47,8 @@ async function tempDir(base: string): Promise { async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) - await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 }) + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 8263dad6fe..b8c86d95b2 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** @@ -45,7 +46,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } - await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 }) + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 4f92ba38f0..58788edcf7 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,7 +15,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' -import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -39,9 +40,15 @@ const passthrough = (argv: readonly string[]): ConfinedArgv => /** * Boot a context with a recording fake `ctx.sandbox` (behavior injectable - * per test) and the executor under test on top of it. + * per test), the shared `ctx.sandboxPolicy` (mode + workspaceRoot), and the + * executor under test on top of them. `mode`/`workspaceRoot` route to the + * policy service; the rest (cwd, graceMs, timeoutMs) to the executor. */ -async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) { +async function setup( + config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {}, + behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough, +) { + const { mode, workspaceRoot, ...execConfig } = config const calls: ConfineCall[] = [] class FakeSandboxProvider extends SandboxProvider { confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { @@ -51,7 +58,11 @@ async function setup(config: Config = {}, behavior: (argv: readonly string[], po } const ctx = new Context() await ctx.plugin(FakeSandboxProvider) - await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config }) + await ctx.plugin(SandboxPolicyService, { + ...mode !== undefined ? { mode } : {}, + ...workspaceRoot !== undefined ? { workspaceRoot } : {}, + }) + await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } return { ctx, bash, calls } @@ -86,14 +97,14 @@ describe('the provider hand-off', () => { expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) }) - it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => { - const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() }) + it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => { + const { bash, calls } = await setup({ mode: 'workspace-write' }) const result = await bash.run(bash.resolve({ command: 'true' })) expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) - expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) }) + expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) }) }) - it('an explicit workspaceRoot wins over cwd', async () => { + it('an explicit workspaceRoot on the policy wins', async () => { const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() }) await bash.run(bash.resolve({ command: 'true' })) expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws')) diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 8ae25a8d39..9c01442042 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** @@ -43,7 +44,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } - await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 }) + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tsconfig.json b/packages/bash/bash-sandbox/tsconfig.json index 6dad98d54f..531ae140ea 100644 --- a/packages/bash/bash-sandbox/tsconfig.json +++ b/packages/bash/bash-sandbox/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../../vendor/schemastery" - }, { "path": "../../util/brand" }, @@ -26,6 +23,9 @@ { "path": "../../sandbox/sandbox" }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../bash/bash" }, diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index bfa71d73e3..eb92cfdac5 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -24,13 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 63c5757175..31135941c2 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -19,7 +19,6 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts' export { BashTaskId, OwnerToken } from './types.ts' -export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' export type { BashExecRequest, BashExecSpec, diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 39dbc162c6..e36c9b32e7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -130,7 +130,7 @@ export interface BashExecRequest { * consumer sets it only from an explicit policy source — an * `'allowed-once'` grant a human just issued through `ctx.approval` (the * escalation flow in the sandbox RFC § Escalation, which outranks), or the - * session's standing override folded from its own `bash/sandbox-mode` + * session's standing override folded from its own `sandbox/mode` * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session * choice). A sandboxing executor confines THIS call under the given mode; * a non-sandboxing executor carries the field and confines nothing (the diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 13d297a292..bbc2fec3cd 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../../sandbox/sandbox" - }, - { - "path": "../../core/session" } ] } diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index eec5d79ccb..3f71e14b87 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -25,8 +25,8 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 31e6512ae0..694ff8f7e4 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -44,7 +44,7 @@ * that the composition cannot honor. * * Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a - * standing sandbox-mode override — the `bash/sandbox-mode` event fold from + * standing sandbox-mode override — the `sandbox/mode` event fold from * `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each * call is stamped `escalation grant > session override > executor default`. * The prompt deliberately does NOT state the mode and no switch is narrated: @@ -60,14 +60,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' -import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' // Side-effect type import: declaration-merges `ctx.approval`, consumed // opportunistically by the escalation gate (`ctx.get('approval')` — the seam // stays optional at runtime, same pattern as dsh-tools' ask routing). import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' +import { + ESCALATION_TARGETS, + approveEscalation, + escalationHintMarker, + sandboxDenialMarker, + validateEscalationArgs, +} from '@deepseek-ai/dsh-sandbox' +import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' export const name = 'tool-bash' @@ -93,15 +100,9 @@ function validateBashArgs(args: BashToolArgs): void { if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } - if (args.sandbox_permissions !== undefined && args.justification === undefined) { - throw new Error('invalid escalation: sandbox_permissions requires a justification') - } - if (args.justification !== undefined && args.sandbox_permissions === undefined) { - throw new Error('invalid escalation: justification is only valid together with sandbox_permissions') - } - if (args.justification !== undefined && args.justification.trim().length === 0) { - throw new Error('invalid justification: expected a non-empty sentence') - } + // The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is + // the shared rule both enforcing families validate identically. + validateEscalationArgs(args.sandbox_permissions, args.justification) } /** @@ -132,27 +133,6 @@ interface BashToolArgs { justification?: string } -/** - * The strictly-wider table: what a call whose effective mode is the key may - * escalate TO. Checked at EXECUTION, never baked into the schema — the - * schema's enum is {@link ESCALATION_TARGETS}, because schemas are - * registry-global while the effective mode is per-call truth. - */ -const WIDER_MODES: Record = { - 'read-only': ['workspace-write', 'danger-full-access'], - 'workspace-write': ['danger-full-access'], -} - -/** - * The closed escalation-target vocabulary — every mode a call could ever - * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised - * whenever the mounted executor confines: cutting the enum down to the modes - * wider than the executor's DEFAULT would strand a session whose effective - * mode sits below it (a `danger-full-access` default would advertise nothing - * while a narrower-switched session stays confined with no lever). - */ -const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access'] - /** * The bash tool's static description. The base text is byte-stable regardless * of composition (it is part of the pinned snapshot header); the escalation @@ -222,13 +202,13 @@ export function renderResult( // stays the LAST line (exitStatus() anchors its parse there). Denial is a // reported fact like timeout: the model decides how to react. if (result.sandbox?.denied) { - markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) + markers.push(sandboxDenialMarker(result.sandbox.mode)) // The same-turn nudge lives at the decision point: only when this // composition advertises the fields (a lever is never hinted that the // schema does not offer), and inside the sandbox marker family so the // exit-code marker stays the last line. if (escalationModes.length > 0) { - markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + markers.push(escalationHintMarker('command')) } } // Timeout is reported independently of how the process actually ended: a @@ -482,7 +462,7 @@ export function apply(ctx: Context): void { /** * The session's standing mode override for an ordinary (non-escalating) - * call: the `bash/sandbox-mode` fold of the calling agent's log, stamped + * call: the `sandbox/mode` fold of the calling agent's log, stamped * onto the request so EXECUTION follows the same effective mode the prompt * section states. Weakest precedence — an escalation grant (freshly * approved for exactly this call) outranks it, and without either the @@ -495,58 +475,30 @@ export function apply(ctx: Context): void { /** * Resolve a sandbox-escalation request through `ctx.approval` BEFORE - * anything executes. Returns the granted mode to stamp onto the bash - * request; throws the distinct fail-closed text for every other path (no - * service composed, an agent-less execution, a rejection, a cancellation, - * an unanswerable ask) — the registry turns the throw into this call's - * isError result, and nothing has run. The seam is consumed - * opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a - * deployment without it degrades per call, never at registration. + * anything executes, delegating the shared fail-closed sequence (strict + * widening, channel resolution, outcome mapping) to + * {@link approveEscalation}. This tool contributes only the composition + * guard (the fields are unadvertised without a sandboxing executor, yet + * schema validation checks advertised keys only, so an unadvertised + * `sandbox_permissions` still reaches execute) and the channel closure over + * `ctx.approval` — consumed opportunistically (`ctx.get`, the dsh-tools + * ask-routing pattern) so a deployment without it degrades per call. */ - const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise => { - // Schema validation only checks ADVERTISED keys, so an unadvertised - // `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a - // human is never prompted to "escalate" a sandbox that is not there. When - // the fields ARE advertised, the registry's SchemaSpec enum has already - // pinned `mode` to this ladder for every caller. + const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise => { if (escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') } - // Strict widening is an EXECUTION check against the call's effective - // mode — session override ?? executor default, the same fold ordinary - // calls are stamped with — deliberately not a schema constraint (the - // enum is the closed target vocabulary; the effective mode is per-call - // truth). A non-widening request fails closed here and never prompts a - // human. const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode - if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) { - throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`) - } - const approval = ctx.get('approval') - if (approval === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`) - } - if (exec.agent === undefined) { - throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`) - } - const outcome = await approval.request({ - agent: exec.agent, - toolName: 'bash', - callId: exec.callId, - // Self-contained for the audit trail: approval/asked stores this - // reason, and the target mode is part of the grant's identity. - reason: `escalate sandbox to ${mode}: ${justification}`, - ...exec.signal ? { signal: exec.signal } : {}, - }) - switch (outcome) { - // The SchemaSpec enum already pinned `mode` to the closed target - // vocabulary; the per-call check above proved it is strictly wider. - case 'allowed-once': return mode as SandboxMode - case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`) - case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) - case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`) - default: return assertNever(outcome, 'ApprovalOutcome') - } + return approveEscalation( + { requestedMode: mode, justification, effectiveMode, subject: 'command' }, + { + approver: ctx.get('approval'), + agent: exec.agent, + callId: exec.callId, + toolName: 'bash', + ...exec.signal ? { signal: exec.signal } : {}, + }, + ) } ctx.tools.register(defineTool({ @@ -589,7 +541,7 @@ export function apply(ctx: Context): void { // An ordinary call carries the session's standing override instead — // grant > session override > executor default (see sessionOverride). const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined - ? await approveEscalation(args.sandbox_permissions, args.justification, exec) + ? await approveBashEscalation(args.sandbox_permissions, args.justification, exec) : sessionOverride(exec) // Default the workdir to the calling agent's session cwd so each ACP // session runs in its own workspace (see resolveWorkdir); an explicit @@ -649,9 +601,9 @@ export function apply(ctx: Context): void { // hint). Background denials are only classifiable once the task // settles (the classifier needs the whole stderr), so the marker // rides every read that sees the settled task. - text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]` + text += `\n${sandboxDenialMarker(read.task.sandbox.mode)}` if (escalationModes.length > 0) { - text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]' + text += `\n${escalationHintMarker('command')}` } } return Promise.resolve([{ type: 'text', text }]) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 12b4a53958..867ae359da 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { BashExecutor, BashTaskId, setSandboxMode } from '@deepseek-ai/dsh-bash' +import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import { Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -15,6 +15,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import { SandboxProvider } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' +import { SandboxPolicyService, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' @@ -1040,6 +1041,7 @@ describe('sandbox rendering', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } @@ -1106,6 +1108,7 @@ describe('sandbox rendering', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(FakeProvider) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } @@ -1126,6 +1129,7 @@ describe('sandbox rendering', () => { runnerCommand: ['bash', '-c', `printf '${signature}\\n' >&2; exit 125`, 'custom-runner'], runnerFailureSignatures: [signature], }) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } @@ -1144,6 +1148,7 @@ describe('sandbox rendering', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG) + await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } @@ -1167,7 +1172,8 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG) - await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} }) + await ctx.plugin(SandboxPolicyService, mode !== undefined ? { mode } : {}) + await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) const bash = ctx.bash as SandboxBashExecutor bash.internals = { spillDir } if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {}) @@ -1381,7 +1387,7 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { }) }) -describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => { +describe('per-session sandbox mode (the sandbox/mode fold)', () => { /** Compose the real sandbox stack (passthrough runner) at a given default mode. */ async function setupModal(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'read-only', opts: { approval?: boolean } = {}) { const ctx = new Context() @@ -1389,7 +1395,8 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG) - await ctx.plugin(SandboxBashExecutor, { graceMs: 200, mode }) + await ctx.plugin(SandboxPolicyService, { mode }) + await ctx.plugin(SandboxBashExecutor, { graceMs: 200 }) ;(ctx.bash as SandboxBashExecutor).internals = { spillDir } if (opts.approval === true) await ctx.plugin(ApprovalService) await ctx.plugin(ToolBash) diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index c4d738c7dd..b9091bd067 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../llm/llm" - }, { "path": "../../core/tools" }, @@ -34,6 +31,9 @@ }, { "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 392e3592ad..0f9583855b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -121,8 +121,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract readText(target: FsTarget, signal?: AbortSignal): Promise', 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', - 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise', - 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise', + 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', + 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise', ], }, { @@ -151,6 +151,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv', ], }, + { + key: 'sandboxPolicy', + summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', + methods: [], + }, { key: 'sessionPersistence', summary: 'Abstract durable session-persistence service.', diff --git a/packages/fs/README.md b/packages/fs/README.md index ec3bb62afb..8993bd292f 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,10 +6,11 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) | | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox RFC](../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. ## No timeouts on file IO diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md new file mode 100644 index 0000000000..649dfb1e41 --- /dev/null +++ b/packages/fs/fs-sandbox/README.md @@ -0,0 +1,19 @@ +# dsh-fs-sandbox — the sandbox-enforcing filesystem backend + +`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. + +Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots. + +## The fence + +The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default: + +- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`. +- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught. +- `danger-full-access` — delegates unfenced. + +## Threat model: a policy fence, not a kernel boundary + +The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here. + +A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md). diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json new file mode 100644 index 0000000000..3e562984ae --- /dev/null +++ b/packages/fs/fs-sandbox/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-fs-sandbox", + "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-fs-local": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts new file mode 100644 index 0000000000..d12858e6f0 --- /dev/null +++ b/packages/fs/fs-sandbox/src/index.ts @@ -0,0 +1,155 @@ +/** + * `SandboxedFileSystem`: the sandbox-enforcing implementation of the + * `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all + * text-storage mechanics — resolve, stat, read/stream, list, the atomic + * write and the read-match-write edit critical section — are the local + * implementation's, verbatim; this package adds only the per-call MODE fence + * on the two mutations. Reads pass through untouched: every mode permits + * reading. + * + * The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path, + * NOT a kernel boundary — the operations are the seam's own (open, rename), + * and only the target path is untrusted, so canonicalize-then-contain is the + * complete answer to this surface. Kernel-grade isolation of untrusted CODE + * stays `ctx.bash`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the + * `code-runtime` stance: containment, not a security boundary. The residual + * TOCTOU (an ancestor symlink swapped between the containment re-check and the + * syscall) is narrowed by re-canonicalizing immediately before delegating and + * is accepted for this threat model. + * + * Per-call mode: `read-only` denies every mutation; `workspace-write` allows a + * mutation only when the target canonicalizes under the workspace root or a + * platform temp area (the SAME writable-root set the Seatbelt profile grants, + * derived from the one `writableRoots` function so bash and fs cannot drift); + * `danger-full-access` delegates unfenced. A denial throws the structured + * `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel + * stderr), because an in-process fence knows exactly what it refused. The + * escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`), + * exactly as bash's does. + * + * @module @deepseek-ai/dsh-fs-sandbox + */ + +import { sep } from 'node:path' +import { Context } from 'cordis' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { writableRoots } from '@deepseek-ai/dsh-sandbox' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' + +/** + * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve + * base for relative paths). The sandbox default (mode + `workspace-write` + * boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home + * both enforcing families share. + */ +export type Config = LocalConfig + +/** Whether `path` is `root` itself or lies beneath it (both already canonical). */ +function isUnder(path: string, root: string): boolean { + if (path === root) return true + const prefix = root.endsWith(sep) ? root : root + sep + return path.startsWith(prefix) +} + +/** + * Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it + * INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole + * swap — the model-facing tools are untouched). Its configured default mode is + * the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's + * `sandbox/mode` override and stamps the effective mode onto each mutation, + * while an approved escalation may stamp a strictly wider mode for one call. + */ +export class SandboxedFileSystem extends LocalFileSystem { + static inject = ['sandboxPolicy'] + + private readonly defaultMode: SandboxMode + /** + * The canonical roots a `workspace-write` mutation may land under, computed + * once (the workspace root and platform temp areas are fixed for the + * provider's lifetime): the same set {@link writableRoots} gives every + * enforcement dialect, so the fs fence and the bash runner agree. + */ + private readonly writableRoots: string[] + + constructor(ctx: Context, config: Config) { + super(ctx, config) + this.defaultMode = ctx.sandboxPolicy.defaultMode + this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot }) + } + + /** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */ + override get sandboxMode(): SandboxMode { + return this.defaultMode + } + + /** + * Fence the write by the per-call mode, then delegate to the inherited + * atomic write. See {@link assertWritable}. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @returns the write outcome from the inherited backend. + */ + override async writeText( + target: FsTarget, + content: string, + expected?: FsWriteIntent, + signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise { + await this.assertWritable(target, sandboxMode) + return super.writeText(target, content, expected, signal) + } + + /** + * Fence the edit by the per-call mode, then delegate to the inherited + * atomic edit. See {@link assertWritable}. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @param sandboxMode - the per-call mode; omit to use the deployment default. + * @returns the edit outcome from the inherited backend. + */ + override async editText( + target: FsTarget, + edit: FsEditRequest, + expected?: { version: FsVersion }, + signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise { + await this.assertWritable(target, sandboxMode) + return super.editText(target, edit, expected, signal) + } + + /** + * Enforce the per-call mode against `target` before delegating the mutation. + * `read-only` denies; `workspace-write` re-canonicalizes the target NOW + * (`resolve` realpaths the deepest existing ancestor, reflecting a + * concurrently swapped symlink) and requires containment under a writable + * root; `danger-full-access` allows. Throws the structured + * `FS_SANDBOX_DENIED` on refusal — the tool layer maps it to the model-facing + * `[sandbox: …]` marker and the escalation hint. + */ + private async assertWritable(target: FsTarget, sandboxMode?: SandboxMode): Promise { + const mode = sandboxMode ?? this.defaultMode + if (mode === 'danger-full-access') return + if (mode === 'read-only') { + throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED') + } + // workspace-write: containment on the FRESH canonical path (catches a + // symlink ancestor swapped since the tool resolved this target). + const fresh = await this.resolve(target.displayPath) + if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) { + throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED') + } + } +} + +export default SandboxedFileSystem diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts new file mode 100644 index 0000000000..095cedd695 --- /dev/null +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -0,0 +1,224 @@ +/** + * Tests for the sandbox-enforcing filesystem backend: the per-call mode fence + * on write/edit (read-only denies, workspace-write contains, danger-full-access + * passes through), reads always passing through, the capability fact, and the + * containment matrix — `..` traversal, absolute paths outside, and symlink + * escapes (a symlinked directory inside the workspace pointing out, and a new + * file created under one). The fence is exercised on a real filesystem: a + * denied write leaves no file on disk. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsTarget } from '@deepseek-ai/dsh-fs' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' + +let base: string +let workspace: string +let outside: string +let ctx: Context +let fs: SandboxedFileSystem +let fiber: Awaited> + +async function boot(mode: SandboxMode): Promise { + ctx = new Context() + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) + fs = ctx.fs as SandboxedFileSystem +} + +beforeEach(async () => { + // Base under HOME, deliberately NOT tmpdir: `workspace-write` grants /tmp and + // os.tmpdir() (parity with the bash runner), so an "outside" dir under tmpdir + // would be legitimately writable. Sibling dirs under HOME are outside every + // grant, so containment failures are real denials. (The bwrap e2e roots its + // workspaces under HOME for the same reason.) + base = await mkdtemp(join(homedir(), '.dsh-fssbx-')) + workspace = join(base, 'ws') + outside = join(base, 'out') + await mkdir(workspace) + await mkdir(outside) +}) +afterEach(async () => { + await fiber?.dispose() + await rm(base, { recursive: true, force: true }) +}) + +/** Resolve a path through the backend and return its target. */ +function target(path: string): Promise { + return fs.resolve(path) +} + +describe('the capability fact', () => { + it('reports the deployment default mode (what the tool layer advertises against)', async () => { + await boot('workspace-write') + expect(fs.sandboxMode).toBe('workspace-write') + }) +}) + +describe('read-only', () => { + beforeEach(() => boot('read-only')) + + it('denies write, leaving no file on disk', async () => { + const path = join(workspace, 'denied.txt') + await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(existsSync(path)).toBe(false) + }) + + it('denies edit of an existing file (the content is unchanged)', async () => { + const path = join(workspace, 'file.txt') + await writeFile(path, 'original') + await expect(fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(await readFile(path, 'utf8')).toBe('original') + }) + + it('allows reads (every mode permits reading)', async () => { + const path = join(workspace, 'readable.txt') + await writeFile(path, 'hello') + expect(await fs.readText(await target(path))).toBe('hello') + }) +}) + +describe('workspace-write containment', () => { + beforeEach(() => boot('workspace-write')) + + it('a write under the workspace lands', async () => { + const path = join(workspace, 'nested', 'ok.txt') + const outcome = await fs.writeText(await target(path), 'inside') + expect(outcome.operation).toBe('create') + expect(await readFile(path, 'utf8')).toBe('inside') + }) + + it('a write to the platform temp area lands (parity with the bash runner grant)', async () => { + const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt') + await fs.writeText(await target(path), 'temp') + expect(await readFile(path, 'utf8')).toBe('temp') + }) + + it('an absolute path outside the workspace is denied, no file created', async () => { + const path = join(outside, 'escape.txt') + await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(existsSync(path)).toBe(false) + }) + + it('a `..` traversal out of the workspace is denied', async () => { + const path = join(workspace, '..', 'sibling-escape.txt') + await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(existsSync(join(workspace, '..', 'sibling-escape.txt'))).toBe(false) + }) + + it('a symlinked directory inside the workspace pointing OUT is denied (canonicalized before containment)', async () => { + // workspace/link -> outside ; writing workspace/link/f.txt would land in outside/f.txt. + await symlink(outside, join(workspace, 'link')) + const path = join(workspace, 'link', 'f.txt') + await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(existsSync(join(outside, 'f.txt'))).toBe(false) + }) + + it('a NEW file created under a symlinked-out directory is denied (deepest-ancestor realpath)', async () => { + await symlink(outside, join(workspace, 'link')) + const path = join(workspace, 'link', 'newdir', 'deep.txt') + await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(existsSync(join(outside, 'newdir'))).toBe(false) + }) + + it('an edit outside the workspace is denied; the original is untouched', async () => { + const path = join(outside, 'file.txt') + await writeFile(path, 'original') + await expect(fs.editText(await target(path), { oldString: 'original', newString: 'x', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + expect(await readFile(path, 'utf8')).toBe('original') + }) + + it('an edit inside the workspace lands', async () => { + const path = join(workspace, 'edit.txt') + await writeFile(path, 'original') + const outcome = await fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false }) + expect(outcome.after).toBe('changed') + expect(await readFile(path, 'utf8')).toBe('changed') + }) + + it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => { + // isUnder's path-equals-root branch: the fence allows the root, and the + // write then fails because the root is a directory, not a regular file. + await expect(fs.writeText(await target(workspace), 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) +}) + +describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => { + it('grants writes anywhere: containment against `/` allows any absolute path', async () => { + // A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's + // separator-suffixed-root branch: `/` already ends in the separator, so the + // prefix stays `/` and every absolute path is contained. + const rootCtx = new Context() + await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' }) + const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace }) + const rootFs = rootCtx.fs as SandboxedFileSystem + try { + const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root + await rootFs.writeText(await rootFs.resolve(path), 'anywhere') + expect(await readFile(path, 'utf8')).toBe('anywhere') + } finally { + await rootFiber.dispose() + } + }) +}) + +describe('danger-full-access', () => { + beforeEach(() => boot('danger-full-access')) + + it('writes anywhere, unfenced', async () => { + const path = join(outside, 'free.txt') + await fs.writeText(await target(path), 'free') + expect(await readFile(path, 'utf8')).toBe('free') + }) +}) + +describe('the per-call mode override (escalation)', () => { + it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => { + await boot('read-only') + const path = join(workspace, 'escalated.txt') + // Default read-only would deny; the per-call workspace-write stamp allows it (contained). + await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write') + expect(await readFile(path, 'utf8')).toBe('granted') + // A neighboring plain call still runs under the read-only default. + await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x')) + .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' }) + }) + + it('a danger-full-access stamp bypasses the fence for that call', async () => { + await boot('read-only') + const path = join(outside, 'granted-full.txt') + await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access') + expect(await readFile(path, 'utf8')).toBe('full') + }) +}) + +describe('registration and HMR safety', () => { + it('registers as ctx.fs and unregisters cleanly from a child fiber', async () => { + await boot('workspace-write') + expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem) + await fiber.dispose() + expect(ctx.get('fs')).toBeUndefined() + // Re-mount below the disposed one to prove no lingering registration. + fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) + expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem) + }) +}) + +describe('FsError identity', () => { + it('the denial is a structured FsError distinct from a host permission error', async () => { + await boot('read-only') + const error = await fs.writeText(await target(join(workspace, 'x.txt')), 'x').catch((e: unknown) => e) + expect(error).toBeInstanceOf(FsError) + expect((error as FsError).code).toBe('FS_SANDBOX_DENIED') + }) +}) diff --git a/packages/fs/fs-sandbox/tsconfig.json b/packages/fs/fs-sandbox/tsconfig.json new file mode 100644 index 0000000000..c9e2f629d5 --- /dev/null +++ b/packages/fs/fs-sandbox/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../fs" + }, + { + "path": "../fs-local" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + } + ] +} diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 813cb04e16..8b02766278 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -24,11 +24,13 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 1e0ab03b85..35a3ffe9ec 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -58,6 +58,7 @@ */ import { Context, Service } from 'cordis' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { FsDirEntry, FsEditOutcome, @@ -174,6 +175,22 @@ export abstract class FileSystem extends Service { super(ctx, 'fs') } + /** + * The sandbox mode this backend enforces on mutations BY DEFAULT, or + * `undefined` when it does not confine at all — the capability fact the tool + * layer reads to advertise the escalation fields honestly (mirrors + * `BashExecutor.sandboxMode`). The base class and the bare local backend + * report `undefined`; a sandboxing backend (`@deepseek-ai/dsh-fs-sandbox`) + * overrides it with the deployment default. A session override may make the + * effective mode narrower or wider, so strict escalation widening is checked + * per call rather than encoded in this default-relative fact. + * @returns the configured default mode of a sandboxing backend; `undefined` + * for a backend that never confines. + */ + get sandboxMode(): SandboxMode | undefined { + return undefined + } + /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May * perform I/O (a remote/sandboxed backend may need a round-trip to map a path @@ -238,9 +255,18 @@ export abstract class FileSystem extends Service { * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. * @param signal - aborts before the atomic rename takes effect. + * @param sandboxMode - the per-call sandbox mode this write runs under; a + * sandboxing backend fences the write by it, the bare backend ignores it. + * Omit to leave the backend its own default. * @returns the outcome, including the version the write produced. */ - abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise + abstract writeText( + target: FsTarget, + content: string, + expected?: FsWriteIntent, + signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise /** * Apply a literal edit to an existing UTF-8 text file. When `expected` is @@ -252,9 +278,18 @@ export abstract class FileSystem extends Service { * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. * @param signal - aborts before the atomic rename takes effect. + * @param sandboxMode - the per-call sandbox mode this edit runs under; a + * sandboxing backend fences the edit by it, the bare backend ignores it. + * Omit to leave the backend its own default. * @returns the outcome, including the version the edit produced. */ - abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise + abstract editText( + target: FsTarget, + edit: FsEditRequest, + expected?: { version: FsVersion }, + signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise } export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f6f5b8005f..bfad9df358 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -173,6 +173,7 @@ export type FsErrorCode = | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' | 'FS_PERMISSION_DENIED' + | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index a352aea65a..eb981277c7 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../util/brand" }, - { "path": "../../llm/llm" } + { "path": "../../llm/llm" }, + { "path": "../../sandbox/sandbox" } ] } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 7e7b78aa38..e6c074c35e 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -28,9 +28,12 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { @@ -41,9 +44,12 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 220c850d69..20434f1b67 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' +import type { FsSandboxSurface } from './sandbox.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -29,6 +30,20 @@ interface EditInput { replaceAll: boolean } +/** + * The `edit` tool's validated argument shape: the base parameters plus the two + * escalation fields, advertised only under a confining `ctx.fs` (absent from + * the schema otherwise, so the validator rejects them before `execute`). + */ +interface EditToolArgs { + file_path: string + old_string: string + new_string: string + replace_all?: boolean + sandbox_permissions?: string + justification?: string +} + /** * Validate value constraints the schema DSL can't express: a non-blank * `file_path`, a non-empty `old_string`, and `old_string !== new_string` @@ -63,8 +78,9 @@ export function formatEditOutput(displayPath: string, replaceAll: boolean): stri /** * Register the `edit` tool and its system-prompt guidance. * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + * @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping). */ -export function applyEditTool(ctx: Context): void { +export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { ctx.systemPrompt.section({ name: 'tool:edit', order: 102, @@ -79,21 +95,32 @@ export function applyEditTool(ctx: Context): void { old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' }, new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' }, replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' }, + ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseEditArgs(args) + // Resolve the per-call sandbox mode (escalation grant > session override + // > backend default) BEFORE anything executes. + const sandboxMode = await sandbox.stampMode('edit', args, exec) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). // No stat — the bare default never manufactures a version basis. const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) - const outcome = await ctx.fs.editText( - target, - { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, - intent, - exec.signal, - ) + let outcome + try { + outcome = await ctx.fs.editText( + target, + { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, + intent, + exec.signal, + sandboxMode, + ) + } catch (error: unknown) { + // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through. + throw sandbox.mapError(error, sandboxMode) + } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) // The result-time applied-hunk diff (before→after with context lines). An diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index f5d0d9ef91..9644b941cb 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -24,10 +24,12 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-user-approval' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' +import { FsSandboxSurface } from './sandbox.ts' export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' export type { ReadToolCaps } from './read.ts' @@ -37,6 +39,8 @@ export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } f export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts' export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts' export type { FsDiffMeta } from './diff.ts' +export { FsSandboxSurface } from './sandbox.ts' +export type { EscalationSchemaFields, FsEscalationArgs } from './sandbox.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' @@ -87,6 +91,10 @@ export function apply(ctx: Context, config: Config): void { maxBytes: resolved.readMaxBytes, streamMinSize: resolved.readStreamMinSize, }) - applyWriteTool(ctx) - applyEditTool(ctx) + // One escalation surface shared by both mutating tools: advertisement gating, + // per-call mode stamping, and denial-marker mapping, all keyed off whether + // the mounted ctx.fs confines (ctx.fs.sandboxMode). + const sandbox = new FsSandboxSurface(ctx) + applyWriteTool(ctx, sandbox) + applyEditTool(ctx, sandbox) } diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts new file mode 100644 index 0000000000..2149ee07a6 --- /dev/null +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -0,0 +1,132 @@ +/** + * The sandbox-escalation surface shared by the `write` and `edit` tools: the + * per-call mode stamp, the advertised escalation fields, and the denial-marker + * mapping — all delegating the vocabulary and the fail-closed approval + * sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash` + * uses), so bash and fs escalate identically. Built ONCE per plugin from + * `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?) + * and shared by both mutating tools. + * + * @module @deepseek-ai/dsh-tool-fs/sandbox + */ + +import type { Context } from 'cordis' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' +import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { FsError } from '@deepseek-ai/dsh-fs' + +/** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */ +export interface FsEscalationArgs { + sandbox_permissions?: string + justification?: string +} + +/** The schema fields for the escalation arguments, spread into a tool's `parameters` when a confining backend is mounted. */ +export interface EscalationSchemaFields { + sandbox_permissions: { type: 'string'; enum: string[]; description: string } + justification: { type: 'string'; description: string } +} + +/** + * The filesystem escalation surface: advertisement gating, per-call mode + * stamping (folding the session's `sandbox/mode` override), the one-approved + * wider retry, and denial-marker mapping. A pure product of `ctx` at plugin + * apply time. + */ +export class FsSandboxSurface { + /** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */ + readonly escalationModes: readonly SandboxMode[] + /** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */ + private readonly defaultMode: SandboxMode | undefined + + constructor(private readonly ctx: Context) { + this.defaultMode = ctx.fs.sandboxMode + this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS + } + + /** + * The escalation schema fields for a mutating tool's `parameters`. Call it + * only under a confining backend (guard on {@link escalationModes}); the + * enum pins the closed target vocabulary, the strict-wider check happens per + * call at execution. + * @returns the two escalation parameter specs. + */ + schemaFields(): EscalationSchemaFields { + return { + sandbox_permissions: { + type: 'string', + enum: [...this.escalationModes], + description: 'The wider sandbox mode this file operation needs. Only valid as a one-shot retry ' + + 'of an operation the sandbox just denied; requires justification and user approval.', + }, + justification: { + type: 'string', + description: 'Required with sandbox_permissions: one sentence for the user explaining ' + + 'why this exact file operation needs the wider access.', + }, + } + } + + /** + * The session's standing mode override for an ordinary (non-escalating) + * call — the `sandbox/mode` fold of the calling agent's log. Undefined for a + * non-confining backend and for agent-less callers. + */ + private sessionOverride(exec: ToolExecution): SandboxMode | undefined { + if (this.defaultMode === undefined || exec.agent === undefined) return undefined + return effectiveSandboxMode(exec.agent.session.events) + } + + /** + * The mode to STAMP onto this mutation: an approved escalation grant (a + * strictly wider retry resolved through `ctx.approval` before anything + * executes), else the session's standing override, else `undefined` (the + * backend applies its own default). Validates the escalation argument + * pairing first. + * @param toolName - the mutating tool's name, for the approval audit trail. + * @param args - the call's escalation arguments. + * @param exec - the tool-execution context (agent, callId, signal). + * @returns the mode to pass to the mutation, or undefined for the backend default. + */ + async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise { + validateEscalationArgs(args.sandbox_permissions, args.justification) + if (args.sandbox_permissions === undefined || args.justification === undefined) { + return this.sessionOverride(exec) + } + if (this.escalationModes.length === 0) { + throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)') + } + const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode + return approveEscalation( + { requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' }, + { + approver: this.ctx.get('approval'), + agent: exec.agent, + callId: exec.callId, + toolName, + ...exec.signal ? { signal: exec.signal } : {}, + }, + ) + } + + /** + * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes an + * error whose text is the shared `[sandbox: …]` denial marker plus the + * same-turn escalation hint, so a policy denial reads identically to bash's; + * any other error passes through unchanged. A `FS_SANDBOX_DENIED` only arises + * under a confining backend, which always advertises the escalation fields, + * so the hint always applies here. + * @param error - the error thrown by the mutation. + * @param stampedMode - the mode stamped onto the call (names the mode in the marker). + * @returns the error to throw — the marker error for a sandbox denial, else the original. + */ + mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown { + if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error + // A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode + // (hence the resolved mode) is defined here. + const mode = (stampedMode ?? this.defaultMode) as SandboxMode + return new Error(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`) + } +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index fd4eec45f3..ffa9e50b37 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' +import type { FsSandboxSurface } from './sandbox.ts' /** * Validate value constraints the schema DSL can't express: only a non-blank @@ -47,11 +48,24 @@ ${verb} file ` } +/** + * The `write` tool's validated argument shape: the base parameters plus the + * two escalation fields, advertised only under a confining `ctx.fs` (absent + * from the schema otherwise, so the validator rejects them before `execute`). + */ +interface WriteToolArgs { + file_path: string + content: string + sandbox_permissions?: string + justification?: string +} + /** * Register the `write` tool and its system-prompt guidance. * @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service. + * @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping). */ -export function applyWriteTool(ctx: Context): void { +export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, @@ -64,15 +78,27 @@ export function applyWriteTool(ctx: Context): void { parameters: { file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' }, content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, + ...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {}, }, - async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { + async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseWriteArgs(args) + // Resolve the per-call sandbox mode (escalation grant > session override + // > backend default) BEFORE anything executes; an escalating call + // resolves approval here and throws its distinct text on any non-grant. + const sandboxMode = await sandbox.stampMode('write', args, exec) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) - const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) + let outcome: FsWriteOutcome + try { + outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode) + } catch (error: unknown) { + // A sandbox denial becomes the shared [sandbox: …] marker (the model + // recognizes it from bash); any other error passes through. + throw sandbox.mapError(error, sandboxMode) + } // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) // Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index c17bd875ba..05238c6593 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -27,6 +27,8 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { @@ -570,3 +572,163 @@ describe('read caps are plugin config', () => { expect('default' in ToolFs).toBe(false) }) }) + +describe('sandbox escalation surface (write/edit)', () => { + /** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */ + class SandboxingFakeFs extends FakeFs { + stamped: (SandboxMode | undefined)[] = [] + override get sandboxMode(): SandboxMode { + return 'workspace-write' + } + override async writeText( + target: FsTarget, + content: string, + expected?: FsWriteIntent, + _signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise { + this.stamped.push(sandboxMode) + return super.writeText(target, content, expected) + } + override async editText( + target: FsTarget, + edit: FsEditRequest, + expected?: { version: FsVersion }, + _signal?: AbortSignal, + sandboxMode?: SandboxMode, + ): Promise { + this.stamped.push(sandboxMode) + return super.editText(target, edit, expected) + } + } + + async function setupConfining(opts: { approval?: boolean } = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SandboxingFakeFs) + await ctx.plugin(FsPolicy) + if (opts.approval === true) await ctx.plugin(ApprovalService) + await ctx.plugin(ToolFs) + return { ctx, fs: ctx.fs as SandboxingFakeFs } + } + + /** A fake agent whose session records appends (the approval audit surface), mid-turn, carrying the given events for the fold. */ + function escalationAgent(events: Array<{ type: string; data?: Record }> = []): object { + return { + id: 'agent-fs-esc', + session: { + header: { version: 0, id: 'sess-fs-esc', createdAt: 0 }, + events: [{ type: 'turn/start' }, ...events], + append: (type: string, data: Record) => { events.push({ type, data }) }, + }, + } + } + + function fsSchema(ctx: Context, name: 'write' | 'edit') { + const schema = ctx.tools.schemas().find(s => s.name === name) + if (!schema) throw new Error(`${name} tool not registered`) + return schema as unknown as { parameters: { properties: Record } } + } + + it('advertises no escalation fields under a non-confining backend', async () => { + const { ctx } = await setup() + expect(ctx.fs.sandboxMode).toBeUndefined() + for (const name of ['write', 'edit'] as const) { + const props = fsSchema(ctx, name).parameters.properties + expect(props['sandbox_permissions']).toBeUndefined() + expect(props['justification']).toBeUndefined() + } + }) + + it('advertises the closed target vocabulary on write and edit under a confining backend', async () => { + const { ctx } = await setupConfining() + for (const name of ['write', 'edit'] as const) { + const props = fsSchema(ctx, name).parameters.properties + expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) + expect(props['justification']).toBeDefined() + } + }) + + it('a plain write stamps nothing (backend default) and no session override folds without one', async () => { + const { ctx, fs } = await setupConfining() + await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) + expect(fs.stamped).toEqual([undefined]) + }) + + it('a standing session override folds onto the stamp', async () => { + const { ctx, fs } = await setupConfining() + await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }])) + expect(fs.stamped).toEqual(['read-only']) + }) + + it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => { + const { ctx, fs } = await setupConfining() + fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED') + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) + expect(result.isError).toBe(true) + expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]') + expect(text(result)).toContain('retry this exact operation once with sandbox_permissions') + }) + + it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => { + const { ctx, fs } = await setupConfining() + fs.rejectWith = new FsError('boom', 'FS_IO_ERROR') + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent()) + expect(result.isError).toBe(true) + expect(text(result)).toContain('boom') + expect(text(result)).not.toContain('[sandbox:') + }) + + it('an approved escalation stamps the granted mode onto that write', async () => { + const { ctx, fs } = await setupConfining({ approval: true }) + ctx.on('approval/request', () => Promise.resolve('allowed-once' as const)) + // Pass a signal so the escalation ask forwards it to the approval request + // (the request rides the tool-execution abort signal). + await ctx.tools.execute({ + callId: CallId('call-fs-esc-grant'), + name: 'write', + arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, + agent: escalationAgent() as never, + signal: new AbortController().signal, + }) + expect(fs.stamped).toEqual(['danger-full-access']) + }) + + it('a rejected escalation fails closed with its own text and never mutates', async () => { + const { ctx, fs } = await setupConfining({ approval: true }) + ctx.on('approval/request', () => Promise.resolve('rejected' as const)) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent()) + expect(result.isError).toBe(true) + expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"') + expect(fs.stamped).toEqual([]) + }) + + it('escalation without an approval service fails closed', async () => { + const { ctx } = await setupConfining() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent()) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no approval service is composed') + }) + + it('escalation with an approval service but no agent fails closed', async () => { + const { ctx } = await setupConfining({ approval: true }) + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no agent to route it through') + }) + + it('rejects the escalation argument pairing (one field without the other)', async () => { + const { ctx } = await setupConfining() + const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent()) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('sandbox_permissions requires a justification') + }) + + it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent()) + expect(result.isError).toBe(true) + expect(text(result)).toContain('not available in this composition') + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index f0133b1d2b..d2adddae03 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, { "path": "../fs" }, - { "path": "../fs-policy" } + { "path": "../fs-policy" }, + { "path": "../../sandbox/sandbox" }, + { "path": "../../sandbox/sandbox-policy" }, + { "path": "../../ui/user-approval" } ] } diff --git a/packages/sandbox/README.md b/packages/sandbox/README.md index cb2c7b5747..902b215280 100644 --- a/packages/sandbox/README.md +++ b/packages/sandbox/README.md @@ -1,12 +1,13 @@ # sandbox/ — process-sandbox capability family -The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages. +The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages. | Package | Role | ctx key | |---|---|---| -| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `ctx.sandbox` | +| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` | | `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) | +| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` | The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase). +Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]` through `ctx.sandbox`) and [`fs/fs-sandbox`](../fs/fs-sandbox/) (an in-process path fence, not an argv wrapper — reads `ctx.sandboxPolicy` and enforces the shared mode on write/edit). The cross-family boundary is the sandbox RFC's [cross-family fs sandbox](../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) phase; the shared vocabulary lets both families teach the model one denial marker and one escalation flow. diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md new file mode 100644 index 0000000000..0fdfb4b5a9 --- /dev/null +++ b/packages/sandbox/sandbox-policy/README.md @@ -0,0 +1,23 @@ +# dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`) + +The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads. + +## Why a shared home + +Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision. + +## Config + +- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe). +- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way. + +## Surface + +- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary. +- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events. +- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. +- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. + +## The per-session store + +A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions never see each other's state, and there is no external config store. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json new file mode 100644 index 0000000000..4f8568acb9 --- /dev/null +++ b/packages/sandbox/sandbox-policy/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-sandbox-policy", + "description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts new file mode 100644 index 0000000000..cd7a1545a8 --- /dev/null +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -0,0 +1,84 @@ +/** + * The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the + * deployment's sandbox default — the file-effect {@link SandboxMode} a session + * starts from and the `workspace-write` boundary root — plus the per-session + * override kit (the `sandbox/mode` event, its fold, and its write path, from + * `./session-mode.ts`). + * + * Both enforcing capability families read the SAME policy here: the sandboxed + * bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem + * provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the + * default mode and workspace root, so bash and fs can never confine to + * different roots — the split world the sandbox RFC warns about. The default + * lives here rather than on either executor's config precisely because it is + * one fact two families share. + * + * This service holds only the DEFAULT; the per-session fold + * ({@link effectiveSandboxMode}) is a pure function the tool layers apply to + * stamp each call, so neither the executor nor the provider depends on session + * events. + * + * @module @deepseek-ai/dsh-sandbox-policy + */ + +import { resolve } from 'node:path' +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' + +export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' + +declare module 'cordis' { + interface Context { + sandboxPolicy: SandboxPolicyService + } +} + +/** + * Plugin config: the deployment's sandbox default. All optional — `Config` + * supplies the defaults (`mode: 'read-only'` is the fail-safe default; a + * deployment that wants a workspace-writable agent opts in explicitly). The + * runner choice is NOT here (it is the `ctx.sandbox` provider's config), nor + * is any per-family knob: this is the one shared policy home. + */ +export interface Config { + /** File-sandbox mode a session starts from (default: `read-only`). */ + mode?: SandboxMode + /** + * Absolute root directory `workspace-write` may write under (default: + * `process.cwd()`). Both enforcing families fence against this SAME root. + */ + workspaceRoot?: string +} + +/** + * The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment + * default mode and workspace root; enforcing implementations read + * {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each + * session's `sandbox/mode` override with {@link effectiveSandboxMode} on top. + */ +export class SandboxPolicyService extends Service { + // Inline schema call: the config catalog walks `static Config` statically. + static Config: z = z.object({ + mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'), + // No schema default: process.cwd() is resolved in the constructor so the + // stored root is always absolute regardless of how it was supplied. + workspaceRoot: z.string(), + }) + + /** The deployment default mode — the fallback beneath a session override. */ + readonly defaultMode: SandboxMode + /** The absolute `workspace-write` boundary root both families fence against. */ + readonly workspaceRoot: string + + constructor(ctx: Context, config: Config) { + super(ctx, 'sandboxPolicy') + // schemastery (static Config) already filled `mode`; the cast records that + // runtime fact. `workspaceRoot` has NO schema default, so its fallback to + // the process cwd is real branching, resolved absolute either way. + this.defaultMode = config.mode as SandboxMode + this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd()) + } +} + +export default SandboxPolicyService diff --git a/packages/bash/bash/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts similarity index 52% rename from packages/bash/bash/src/session-mode.ts rename to packages/sandbox/sandbox-policy/src/session-mode.ts index 03ad6e3d7c..62be36501f 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -1,18 +1,21 @@ /** * Per-session sandbox-mode override: the session log as the store. A runtime * switch (an ACP `session/set_config_option`, a test scenario) is recorded as - * one `bash/sandbox-mode` event on the session it applies to; - * `effective = fold(events) ?? the executor's configured default`, so an - * override survives restart by replay, two sessions can never see each - * other's state, and there is no external config store. The event is - * log-only (the `approval/*` precedent): the model learns the mode from the - * prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`, - * never from the event itself. EXECUTION honors the fold in the tool layer — - * it stamps the effective mode onto each call's `BashExecRequest.sandboxMode` - * (weakest-precedence: an escalation grant for the call outranks it) — the - * executor itself stays a config-fixed default plus per-call overrides. + * one `sandbox/mode` event on the session it applies to; + * `effective = fold(events) ?? the deployment default`, so an override + * survives restart by replay, two sessions can never see each other's state, + * and there is no external config store. The event is log-only (the + * `approval/*` precedent): the model learns the mode from the boundary + * markers in the enforcing tools, never from the event itself. EXECUTION + * honors the fold in each tool layer — it stamps the effective mode onto the + * per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's + * `sandboxMode`), weakest-precedence beneath an escalation grant. * - * @module dsh-bash/session-mode + * The override is policy state shared by every enforcing family (bash and + * filesystem alike), so it lives here in the policy package rather than in any + * one capability's seam. + * + * @module dsh-sandbox-policy/session-mode */ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -28,7 +31,7 @@ declare module '@deepseek-ai/dsh-session' { * from position (an event after the log's last `request/header*` was a * runtime switch by the user; see the tool layer's narrator). */ - 'bash/sandbox-mode': { mode: SandboxMode } + 'sandbox/mode': { mode: SandboxMode } } } @@ -36,30 +39,30 @@ declare module '@deepseek-ai/dsh-session' { export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] /** - * The session's sandbox-mode override: the last `bash/sandbox-mode` event in - * the log, or undefined when the session never switched (callers apply the - * executor's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. + * The session's sandbox-mode override: the last `sandbox/mode` event in the + * log, or undefined when the session never switched (callers apply the + * deployment default). The pure fold — resume needs no catch-up machinery + * because replaying the log IS the state. * @param events - session events in log order (other event types are skipped). * @returns the mode of the last switch event, or undefined without one. */ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] as SessionEvent - if (event.type === 'bash/sandbox-mode') return event.data.mode + if (event.type === 'sandbox/mode') return event.data.mode } return undefined } /** * THE write path for a session's sandbox-mode override: appends exactly one - * `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode - * state out of band. Takes effect on the session's next bash call and next - * prompt assembly (the consumers fold on every read). + * `sandbox/mode` event — the switch IS its event; nothing mutates mode state + * out of band. Takes effect on the session's next confined call (bash or fs) + * — the consumers fold on every read. * @param session - the session the override belongs to. - * @param mode - the mode every subsequent bash call in this session runs + * @param mode - the mode every subsequent confined call in this session runs * under (until the next switch). */ export function setSandboxMode(session: Session, mode: SandboxMode): void { - session.append('bash/sandbox-mode', { mode }) + session.append('sandbox/mode', { mode }) } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts new file mode 100644 index 0000000000..52476fdece --- /dev/null +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -0,0 +1,67 @@ +/** + * Tests for the sandbox-policy home: the deployment default (mode + + * workspaceRoot) the service exposes, and the per-session `sandbox/mode` + * override kit (fold + write path) both enforcing families read. + */ + +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' + +async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { + const ctx = new Context() + await ctx.plugin(SandboxPolicyService, config) + return ctx +} + +describe('SandboxPolicyService', () => { + it('defaults to read-only under the process cwd', async () => { + const ctx = await mounted() + expect(ctx.sandboxPolicy.defaultMode).toBe('read-only') + expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd())) + }) + + it('carries a configured mode and resolves the workspace root absolute', async () => { + const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws/../ws/./sub' }) + expect(ctx.sandboxPolicy.defaultMode).toBe('workspace-write') + expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub')) + }) + + it('rejects a mode outside the closed vocabulary at load', async () => { + const ctx = new Context() + // schemastery rejects the union violation when the plugin loads. + await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow() + }) + + it('unregisters cleanly from a child fiber (HMR safety)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(SandboxPolicyService, {}) + expect(ctx.sandboxPolicy).toBeDefined() + await fiber.dispose() + expect(ctx.get('sandboxPolicy')).toBeUndefined() + }) +}) + +describe('the sandbox/mode session kit', () => { + it('SANDBOX_MODES lists every mode for advertisement and validation', () => { + expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access']) + }) + + it('effectiveSandboxMode folds to the last switch, or undefined without one', () => { + const session = new Session(SessionId('sess-fold')) + expect(effectiveSandboxMode(session.events)).toBeUndefined() + setSandboxMode(session, 'workspace-write') + setSandboxMode(session, 'read-only') + expect(effectiveSandboxMode(session.events)).toBe('read-only') + }) + + it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { + const session = new Session(SessionId('sess-write')) + setSandboxMode(session, 'danger-full-access') + const modeEvents = session.events.filter(e => e.type === 'sandbox/mode') + expect(modeEvents).toHaveLength(1) + expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) + }) +}) diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json new file mode 100644 index 0000000000..fc0c96c6de --- /dev/null +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../sandbox" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/sandbox/sandbox/src/escalation.ts b/packages/sandbox/sandbox/src/escalation.ts new file mode 100644 index 0000000000..e0b9a2ce63 --- /dev/null +++ b/packages/sandbox/sandbox/src/escalation.ts @@ -0,0 +1,189 @@ +/** + * The escalation vocabulary and choreography shared by every sandbox-enforcing + * tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the + * strictly-wider ladder, the argument-pairing validation, the model-facing + * denial/hint markers, and {@link approveEscalation} — the ordered fail-closed + * sequence that resolves a `sandbox_permissions` request through a + * user-approval channel BEFORE anything executes. One home keeps the two + * families' approval ordering and verbatim error texts from drifting apart. + * + * The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}), + * not the approval service type: the tool layer — which owns the agent, the + * call id, and the tool name — closes over `ctx.approval.request(...)` and + * hands the closure down, so this package never depends on the approval or + * agent packages. + * + * @module dsh-sandbox/escalation + */ + +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { SandboxMode } from './index.ts' + +/** + * The strictly-wider table: what a call whose effective mode is the key may + * escalate TO. Checked at EXECUTION, never baked into a tool schema — the + * schema's enum is {@link ESCALATION_TARGETS}, because schemas are + * registry-global while the effective mode is per-call truth. + */ +export const WIDER_MODES: Record = { + 'read-only': ['workspace-write', 'danger-full-access'], + 'workspace-write': ['danger-full-access'], +} + +/** + * The closed escalation-target vocabulary — every mode a call could ever + * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised + * whenever the mounted capability confines: cutting the enum down to the modes + * wider than the composition's DEFAULT would strand a session whose effective + * mode sits below it (a `danger-full-access` default would advertise nothing + * while a narrower-switched session stays confined with no lever). + */ +export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access'] + +/** + * Validate the escalation argument pairing a tool schema cannot express: + * `sandbox_permissions` and `justification` travel together — an approval + * prompt without a reason, or a reason driving nothing, is a malformed ask — + * and the justification must be a non-empty sentence. + * @param sandboxPermissions - the raw `sandbox_permissions` argument, if given. + * @param justification - the raw `justification` argument, if given. + */ +export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void { + if (sandboxPermissions !== undefined && justification === undefined) { + throw new Error('invalid escalation: sandbox_permissions requires a justification') + } + if (justification !== undefined && sandboxPermissions === undefined) { + throw new Error('invalid escalation: justification is only valid together with sandbox_permissions') + } + if (justification !== undefined && justification.trim().length === 0) { + throw new Error('invalid justification: expected a non-empty sentence') + } +} + +/** + * The model-facing denial marker — the one vocabulary both enforcing families + * teach and report, so the model recognizes a policy denial identically + * whether the kernel refused a bash file effect or the filesystem provider's + * fence refused a mutation. + * @param mode - the mode the denied call ran under. + * @returns the marker line, exactly as the model sees it. + */ +export function sandboxDenialMarker(mode: SandboxMode): string { + return `[sandbox: file access denied under ${mode} mode]` +} + +/** + * The same-turn escalation hint that rides a denial when the composition + * advertises the escalation fields — the nudge lives at the decision point so + * the sanctioned retry does not depend on the model recalling the tool + * description. + * @param subject - the family's noun for the denied action (`command` for + * bash, `operation` for a filesystem mutation). + * @returns the hint line, exactly as the model sees it. + */ +export function escalationHintMarker(subject: string): string { + return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]` +} + +/** + * The closed outcome vocabulary of one escalation ask — structurally identical + * to the approval seam's `ApprovalOutcome` so an `ApprovalService.request` + * return is assignable without this package importing it. + */ +export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' + +/** + * The minimal approval-request shape {@link approveEscalation} needs — + * structurally the approval seam's `ApprovalService`, generic over the agent + * type `A` and call-id type `C` so this package resolves escalations through + * `ctx.approval` without importing the approval or agent packages (the tool + * layer infers `A`/`C` as its own `Agent`/`CallId`). + */ +export interface EscalationApprover { + /** + * Ask the human to approve one action, resolving to a closed outcome. + * @param req - the audit-self-contained request (agent, tool, call id, reason, optional signal). + * @returns the human's decision as a closed {@link EscalationOutcome}. + */ + request(req: { agent: A; toolName: string; callId: C; reason: string; signal?: AbortSignal }): Promise +} + +/** + * The approval ingredients an escalating tool hands {@link approveEscalation}: + * the approval requester (`ctx.approval`, or `undefined` when none is + * composed), the calling agent (or `undefined` for an agent-less execution), + * and the call's identity. The tool layer holds all of these; this package + * only judges them. + */ +export interface EscalationApproval { + /** The approval requester (`ctx.approval`), or `undefined` when none is composed. */ + approver: EscalationApprover | undefined + /** The calling agent, or `undefined` for an agent-less execution (fails closed). */ + agent: A | undefined + /** The tool-call id the approval prompt attaches to. */ + callId: C + /** The tool name recorded on the approval request. */ + toolName: string + /** The tool-execution abort signal the approval request rides, when present. */ + signal?: AbortSignal +} + +/** One escalation request, as {@link approveEscalation} judges it. */ +export interface EscalationRequest { + /** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */ + requestedMode: string + /** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */ + justification: string + /** The call's effective mode (session override ?? composition default) the request must strictly widen. */ + effectiveMode: SandboxMode + /** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */ + subject: string +} + +/** + * Resolve a sandbox-escalation request BEFORE anything executes: check strict + * widening against the call's effective mode, then resolve the approval + * channel, then map every outcome — the ordered fail-closed sequence both + * enforcing families share. Returns the granted mode to stamp onto exactly + * this call; throws the distinct verbatim text for every other path (a + * non-widening request, a missing approval service, an agent-less execution, + * a rejection, a cancellation, an unanswerable ask) — the tool registry turns + * the throw into the call's isError result, and nothing has run. A + * non-widening request never prompts a human. + * @param request - the escalation to judge (see {@link EscalationRequest}). + * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}). + * @returns the granted mode, consumed by the one call that asked. + */ +export async function approveEscalation(request: EscalationRequest, approval: EscalationApproval): Promise { + const { requestedMode: mode, effectiveMode, justification, subject } = request + // Strict widening is an EXECUTION check against the call's effective mode — + // deliberately not a schema constraint (the enum is the closed target + // vocabulary; the effective mode is per-call truth). + if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) { + throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`) + } + if (approval.approver === undefined) { + throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`) + } + if (approval.agent === undefined) { + throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`) + } + // Self-contained for the audit trail: approval/asked stores this reason, + // and the target mode is part of the grant's identity. + const outcome = await approval.approver.request({ + agent: approval.agent, + toolName: approval.toolName, + callId: approval.callId, + reason: `escalate sandbox to ${mode}: ${justification}`, + ...approval.signal ? { signal: approval.signal } : {}, + }) + switch (outcome) { + // The schema enum already pinned `mode` to the closed target vocabulary; + // the check above proved it is strictly wider. + case 'allowed-once': return mode as SandboxMode + case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`) + case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) + case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`) + default: return assertNever(outcome, 'EscalationOutcome') + } +} diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 55b9da4540..c94ee9aa0d 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -25,6 +25,17 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +export { + ESCALATION_TARGETS, + WIDER_MODES, + approveEscalation, + escalationHintMarker, + sandboxDenialMarker, + validateEscalationArgs, +} from './escalation.ts' +export type { EscalationApproval, EscalationApprover, EscalationOutcome, EscalationRequest } from './escalation.ts' +export { canonicalPath, writableRoots } from './roots.ts' + /** * File-effect policy a sandbox backend enforces on confined processes. * diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts new file mode 100644 index 0000000000..2d70148cdf --- /dev/null +++ b/packages/sandbox/sandbox/src/roots.ts @@ -0,0 +1,51 @@ +/** + * The writable-root derivation shared by every enforcement dialect that + * expresses a mode as a canonical allow-list: `workspace-write` means "the + * workspace root plus the platform temp areas", and this module is that + * meaning's one home. The Seatbelt profile + * (`@deepseek-ai/dsh-sandbox-local`) and the in-process filesystem fence + * (`@deepseek-ai/dsh-fs-sandbox`) both derive their allow-list here, so "the + * write tool cannot write /tmp but bash can" asymmetries cannot arise between + * them. The bwrap and Landlock dialects keep their own grant spellings (an + * ephemeral `/tmp` mount, launcher-owned flags) — the honest per-runner + * differences recorded in the sandbox RFC — with parity pinned by test. + * + * @module dsh-sandbox/roots + */ + +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type { SandboxPolicy } from './index.ts' + +/** + * Resolve a granted root to the path the enforcement layer actually compares: + * canonical (symlinks resolved), because both Seatbelt filters and the fs + * fence's containment check match resolved paths — `/tmp` IS `/private/tmp` + * on darwin, and an as-spelled grant would match nothing. + * @param path - the root as configured or platform-reported. + * @returns the canonical path, or the spelling as-is when resolution fails + * (a missing root matches nothing until it exists — the conservative + * outcome; inventing a fallback would grant a path the caller never named). + */ +export function canonicalPath(path: string): string { + try { + return realpathSync(path) + } catch { + // realpathSync failed: the path (or a prefix) is missing or unreadable. + return path + } +} + +/** + * The roots one confined execution may WRITE under — the mode's meaning as a + * canonical, deduplicated allow-list. `read-only` allows nothing; + * `workspace-write` allows the policy's workspace root, the host `/tmp`, and + * the per-user platform temp dir (`os.tmpdir()` — the real temp area for + * mkstemp-family tools; omitting it would deny what the mode promises). + * @param policy - the file-effect policy to derive the allow-list from. + * @returns the canonical writable roots; empty exactly under `read-only`. + */ +export function writableRoots(policy: SandboxPolicy): string[] { + if (policy.mode !== 'workspace-write') return [] + return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))] +} diff --git a/packages/sandbox/sandbox/tests/escalation.spec.ts b/packages/sandbox/sandbox/tests/escalation.spec.ts new file mode 100644 index 0000000000..15810d09d5 --- /dev/null +++ b/packages/sandbox/sandbox/tests/escalation.spec.ts @@ -0,0 +1,111 @@ +/** + * Tests for the shared escalation vocabulary and choreography: the strictly- + * wider ladder, the argument-pairing validation, the model-facing markers, and + * {@link approveEscalation}'s ordered fail-closed sequence. Both enforcing tool + * families (`dsh-tool-bash`, `dsh-tool-fs`) delegate here, so the ordering and + * verbatim texts are pinned once, next to the vocabulary that owns them. + */ + +import { describe, expect, it } from 'vitest' +import { + ESCALATION_TARGETS, + WIDER_MODES, + approveEscalation, + escalationHintMarker, + sandboxDenialMarker, + validateEscalationArgs, +} from '@deepseek-ai/dsh-sandbox' +import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox' + +describe('the strictly-wider ladder', () => { + it('read-only escalates to either wider mode; workspace-write only to full access', () => { + expect(WIDER_MODES['read-only']).toEqual(['workspace-write', 'danger-full-access']) + expect(WIDER_MODES['workspace-write']).toEqual(['danger-full-access']) + expect(WIDER_MODES['danger-full-access']).toBeUndefined() + }) + + it('the target enum is the closed set every session could escalate TO (read-only is the floor)', () => { + expect(ESCALATION_TARGETS).toEqual(['workspace-write', 'danger-full-access']) + }) +}) + +describe('validateEscalationArgs', () => { + it('accepts neither field, or both with a non-empty justification', () => { + expect(() => { validateEscalationArgs(undefined, undefined) }).not.toThrow() + expect(() => { validateEscalationArgs('workspace-write', 'because the workspace needs it') }).not.toThrow() + }) + + it('rejects one field without the other, and a blank justification', () => { + expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/) + expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/) + expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/) + }) +}) + +describe('the model-facing markers', () => { + it('the denial marker names the mode', () => { + expect(sandboxDenialMarker('read-only')).toBe('[sandbox: file access denied under read-only mode]') + expect(sandboxDenialMarker('workspace-write')).toBe('[sandbox: file access denied under workspace-write mode]') + }) + + it('the hint marker names the family subject', () => { + expect(escalationHintMarker('command')).toContain('retry this exact command once with sandbox_permissions') + expect(escalationHintMarker('operation')).toContain('retry this exact operation once with sandbox_permissions') + }) +}) + +describe('approveEscalation', () => { + const req = (over: Partial[0]> = {}) => ({ + requestedMode: 'workspace-write', + justification: 'the user asked to write in the workspace', + effectiveMode: 'read-only' as const, + subject: 'command', + ...over, + }) + /** An approver that records the request and returns a fixed outcome. */ + const approver = (outcome: EscalationOutcome, sink?: (req: unknown) => void): EscalationApprover => ({ + request: async (request) => { sink?.(request); return outcome }, + }) + const ingredients = (over: Partial[1]> = {}) => ({ + approver: approver('allowed-once'), + agent: {}, + callId: 'call-1', + toolName: 'bash', + ...over, + }) + + it('grants: returns the requested mode, asking through the approver with the audit reason', async () => { + const seen: { reason?: string }[] = [] + const granted = await approveEscalation(req(), ingredients({ approver: approver('allowed-once', r => seen.push(r as { reason?: string })) })) + expect(granted).toBe('workspace-write') + expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace') + }) + + it('a non-widening request fails closed with its own text and never asks', async () => { + const seen: unknown[] = [] + const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) }) + await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy)) + .rejects.toThrow(/not strictly wider than this call's current "read-only" mode/) + await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy)) + .rejects.toThrow(/not strictly wider/) + expect(seen).toEqual([]) + }) + + it('a missing approval service and an agent-less call each fail closed with distinct text', async () => { + await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/) + await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/) + }) + + it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => { + await expect(approveEscalation(req({ subject: 'operation' }), ingredients({ approver: approver('rejected') }))) + .rejects.toThrow('the user rejected escalating this operation to "workspace-write"') + await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') }))) + .rejects.toThrow('approval for escalating to "workspace-write" was cancelled') + await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') }))) + .rejects.toThrow('no approval channel is available') + }) + + it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => { + await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow() + }) +}) diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts new file mode 100644 index 0000000000..fd0d2cd7bd --- /dev/null +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -0,0 +1,39 @@ +/** + * Tests for the writable-root derivation: the mode's meaning as a canonical + * allow-list. Pinned here so the fs fence and the Seatbelt profile — both + * deriving from `writableRoots` — cannot drift. + */ + +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' + +describe('canonicalPath', () => { + it('resolves symlinks (an existing path realpaths)', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-')) + expect(canonicalPath(dir)).toBe(realpathSync(dir)) + }) + + it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => { + expect(canonicalPath('/does/not/exist/anywhere-xyz')).toBe('/does/not/exist/anywhere-xyz') + }) +}) + +describe('writableRoots', () => { + it('read-only grants nothing', () => { + expect(writableRoots({ mode: 'read-only', workspaceRoot: process.cwd() })).toEqual([]) + }) + + it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => { + const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-')) + const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) + expect(roots).toContain(realpathSync(ws)) + expect(roots).toContain(canonicalPath('/tmp')) + expect(roots).toContain(realpathSync(tmpdir())) + // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide). + expect(new Set(roots).size).toBe(roots.length) + }) +}) diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index fb39b2a0de..96db26698b 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -99,12 +99,12 @@ describe('acp bridge — session config options', () => { // Idle: nothing in the log yet — turn-enclosure forbids a bare append. const session = h.ctx.agents.list()[0]?.session - expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) + expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = session?.events ?? [] expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }]) - expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) + expect(events.filter(e => e.type === 'sandbox/mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }]) expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }]) const turnStart = events.findIndex(e => e.type === 'turn/start') const anchored = events.findIndex(e => e.type === 'permission/preset') @@ -135,7 +135,7 @@ describe('acp bridge — session config options', () => { expect(back.configOptions).toEqual([permissionOption('workspace-write')]) await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] }) const events = h.ctx.agents.list()[0]?.session.events ?? [] - expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false) + expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false) }) it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => { @@ -163,7 +163,7 @@ describe('acp bridge — session config options', () => { const anchored = events.findIndex(e => e.type === 'permission/preset') expect(turnStart).toBeGreaterThanOrEqual(0) expect(anchored).toBeGreaterThan(turnStart) - expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true) + expect(events.some(e => e.type === 'sandbox/mode')).toBe(true) expect(events.some(e => e.type === 'approval/policy')).toBe(true) await h.client.cancel({ sessionId }) await hung @@ -213,7 +213,7 @@ describe('acp bridge — session config options', () => { const agent = h.ctx.agents.list()[0] if (agent === undefined) throw new Error('expected an agent') agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - agent.session.append('bash/sandbox-mode', { mode: 'read-only' }) + agent.session.append('sandbox/mode', { mode: 'read-only' }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // The echo of the derived current is a no-op, not an unknown-value error… const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }) diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index b6833791e4..c436ed0f0d 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -34,6 +35,7 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.6" diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 67e623052b..4f7d29e590 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -1,7 +1,7 @@ /** * User-facing PERMISSION PRESETS: one product-level knob over the two * mechanism knobs. A preset names a bundle — its sandbox mode - * (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a + * (`sandbox/mode`) and its approval policy (`approval/policy`) — so a * user picks `workspace-write` or `danger-full-access` while the mechanism * tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event * records the chosen bundle (the audit fact reverse-mapping cannot recover — @@ -19,7 +19,10 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' +import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +// Side-effect type import: declaration-merges `ctx.bash` (the capability fact +// `sandboxMode` this service reads), without a value dependency on the seam. +import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -33,7 +36,7 @@ declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** * The session's permission preset was switched — log-only (the - * `bash/sandbox-mode` precedent): durable and replayable, never in the + * `sandbox/mode` precedent): durable and replayable, never in the * model transcript. The LAST such event is the session's preset * ({@link effectivePermissionPreset}); the knob events the switch wrote * through follow it in the same turn, and they — not this record of the @@ -48,7 +51,7 @@ declare module '@deepseek-ai/dsh-session' { * runs under while the preset is active — plus its presentation. */ export interface PresetSpec { - /** The `bash/sandbox-mode` value the preset writes through. */ + /** The `sandbox/mode` value the preset writes through. */ sandbox: SandboxMode /** The `approval/policy` value the preset writes through. */ approval: ApprovalPolicy diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 2852b2d96b..3e8f63121b 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -53,7 +53,7 @@ describe('PermissionService', () => { it('a knob state matching no table entry derives custom — a state, not an error', async () => { const ctx = await mounted() const session = freshSession('sess-custom') - session.append('bash/sandbox-mode', { mode: 'read-only' }) + session.append('sandbox/mode', { mode: 'read-only' }) expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) // Switching FROM custom is an ordinary write-through; custom itself is // never a target. @@ -80,7 +80,7 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe('agentish') // A knob drifts: the fold's bundle no longer matches → reverse map wins. session.append('approval/policy', { policy: 'never' }) - session.append('bash/sandbox-mode', { mode: 'danger-full-access' }) + session.append('sandbox/mode', { mode: 'danger-full-access' }) expect(ctx.permission.current(session.events)).toBe('danger-full-access') }) @@ -90,7 +90,7 @@ describe('PermissionService', () => { ctx.permission.set(session, 'danger-full-access') expect(session.events.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], - ['bash/sandbox-mode', { mode: 'danger-full-access' }], + ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) }) @@ -109,12 +109,12 @@ describe('PermissionService', () => { // A knob drifts out from under the preset (a direct setter call, a test // scenario): the session derives custom, and re-asserting the preset is // a real switch again — choice re-recorded, only the drifted knob moves. - session.append('bash/sandbox-mode', { mode: 'read-only' }) + session.append('sandbox/mode', { mode: 'read-only' }) ctx.permission.set(session, 'danger-full-access') const tail = session.events.slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], - ['bash/sandbox-mode', { mode: 'danger-full-access' }], + ['sandbox/mode', { mode: 'danger-full-access' }], ]) }) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 8b9cff62b4..fa31f71f69 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../sandbox/sandbox" }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../bash/bash" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6b0d4519c..dd55bcae72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,6 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -113,10 +110,6 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/bash/bash-sandbox: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -130,6 +123,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -163,6 +159,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -457,6 +456,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -489,6 +491,24 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs-sandbox: + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/tool-fs: dependencies: diff: @@ -519,6 +539,12 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -528,6 +554,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -718,6 +747,22 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/sandbox/sandbox-policy: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': @@ -1280,28 +1325,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) - packages/ui/permission: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../../bash/bash - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../user-approval - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) - packages/ui/jsonrpc: dependencies: schemastery: @@ -1346,6 +1369,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/permission: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': @@ -1762,6 +1810,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a843b2f04a..5a961ae355 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 235b8f21fa..27e1234a86 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1802, "docs/AGENTS.md": 1315, - "docs/architecture.md": 1790, + "docs/architecture.md": 1800, "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a970499268..c09a9e1bda 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -180,6 +180,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['bash-sandbox'], note: 'Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement.', }, + { + key: 'sandboxPolicy', + pkg: 'sandbox', + title: 'Sandbox policy home', + mode: 'core', + implementations: [], + consumers: ['bash-sandbox', 'fs-sandbox', 'tool-bash', 'tool-fs'], + note: 'The one home for the deployment default mode + workspace root and the per-session `sandbox/mode` override; both enforcing families read it so bash and fs cannot confine to different roots.', + }, { key: 'approval', pkg: 'approval', @@ -212,10 +221,10 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'fs', title: 'Filesystem provider seam', mode: 'seam', - implementations: ['fs-local'], + implementations: ['fs-local', 'fs-sandbox'], consumers: ['tool-fs'], companions: ['fs-policy'], - note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.', + note: 'tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate.', }, { key: 'compact', diff --git a/tsconfig.build.json b/tsconfig.build.json index 6deb2c6e01..57d94221a6 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -41,11 +41,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, diff --git a/tsconfig.json b/tsconfig.json index dd283ec5d7..572b8ee31c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,11 +50,13 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, + { "path": "./packages/sandbox/sandbox-policy" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, { "path": "./packages/fs/fs-policy" }, + { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, From e723f233fb090fd2743f8f0dcca9db93aaf1dcbd Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 14 Jul 2026 23:41:27 +0800 Subject: [PATCH 009/132] docs(acp-agent): fs tools ride the sandbox policy under every mode The README predated dsh-fs-sandbox and still said filesystem tools were omitted from the confined default; read/write/edit now confine under the same workspaceRoot as bash. --- examples/acp-agent/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 8f611b7d67..e459c31ba2 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -29,7 +29,7 @@ Add to your Zed `settings.json` under `agent_servers`: } ``` -The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). Filesystem tools are omitted from the confined default because they execute in-process and do not ride the bash sandbox. +The editor sets each session's `cwd` to the project it opens, and bash uses that directory as its workdir. The current sandbox write boundary is nevertheless fixed when the server starts (`workspaceRoot: process.cwd()`), so launch the server from the workspace it should be allowed to modify; making that root session-scoped is deferred in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). The filesystem tools now ride the same sandbox policy through [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), so `read`/`write`/`edit` are available under every mode and confined to the same `workspaceRoot`. ## Snapshot tests (record-once / replay-deterministic) @@ -37,11 +37,11 @@ This example hosts the ACP snapshot suite. `dsh-llm-replay` reconstructs model s ## Permissions and sandboxing -The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/), [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/), [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/), and [`@deepseek-ai/dsh-permission`](../../packages/ui/permission/). Bash starts in `workspace-write`; a denied operation returns a structured marker, and a retry with `sandbox_permissions` plus `justification` becomes a one-shot `session/request_permission` prompt in the editor. "Allow once" runs exactly that retry under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). +The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/), [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/), [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/), [`@deepseek-ai/dsh-fs-sandbox`](../../packages/fs/fs-sandbox/), [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/), and [`@deepseek-ai/dsh-permission`](../../packages/ui/permission/). Bash and the `read`/`write`/`edit` tools start in `workspace-write`; a denied operation returns a structured marker, and a retry with `sandbox_permissions` plus `justification` becomes a one-shot `session/request_permission` prompt in the editor. "Allow once" runs exactly that retry under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). - **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined file access plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. - **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. -- **The boundary is bash-only and config-fixed today**: in-process filesystem tools are omitted from the confined live default, while the sandbox workspace root remains the server's launch directory. +- **The boundary spans bash and the filesystem tools, and is config-fixed today**: bash confines through the OS runner and the `read`/`write`/`edit` tools through an in-process path fence ([`dsh-fs-sandbox`](../../packages/fs/fs-sandbox/)), both keyed to the same `workspaceRoot` — which remains the server's launch directory (a per-session root is deferred). `tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The snapshot suite uses the same tree: snapshot mode starts at `danger-full-access` so established fixtures remain runner-independent, while the permission-switching and escalation inputs explicitly select `workspace-write` before exercising that policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. From 91247babd91749d720aa862036977592ca9f42a0 Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 15 Jul 2026 00:17:29 +0800 Subject: [PATCH 010/132] test(acp-agent): snapshot the fs sandbox escalation arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recorded scenario where the model writes a workspace file in one write call with sandbox_permissions=danger-full-access + justification, the scripted client allows once, and the escalated write lands — the fs twin of escalation-approved, covering the fs escalation approval arc (only bash had a snapshot before). Uses a new workspace-internal file to avoid the read-before-edit detour, keeping the recorded path single and deterministic. --- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../fs-escalation-approved/input.json | 11 ++ .../fs-escalation-approved/session.jsonl | 127 ++++++++++++++++++ .../stdout.golden.jsonl | 52 +++++++ 4 files changed, 191 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/fs-escalation-approved/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 64c55fa179..6b8b87e1e5 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -125,6 +125,7 @@ const SCENARIOS: Scenario[] = [ { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'sandbox' }, { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, + { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' }, ] defineAcpSnapshotSuite({ diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/input.json b/examples/acp-agent/tests/snapshots/fs-escalation-approved/input.json new file mode 100644 index 0000000000..d6d8d2b8c6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/input.json @@ -0,0 +1,11 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "setConfigOption", "configId": "permission", "value": "workspace-write" }, + { "op": "prompt", "text": "Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE." } + ], + "permissionAnswers": [ + { "kind": "allow_once" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl new file mode 100644 index 0000000000..16e0b8dc95 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -0,0 +1,127 @@ +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd"} +{"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"permission/preset","seq":1,"time":1784045702343,"data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","seq":2,"time":1784045702343,"data":{"mode":"workspace-write"}} +{"type":"approval/policy","seq":3,"time":1784045702343,"data":{"policy":"ask"}} +{"type":"user/message","seq":4,"time":1784045702343,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":5,"time":1784045702345,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":6,"time":1784045702345,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":7,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":8,"time":1784045703046,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":9,"time":1784045703162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":10,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":11,"time":1784045703172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":12,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":13,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":14,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":15,"time":1784045703173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":16,"time":1784045703199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":17,"time":1784045703225,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":18,"time":1784045703251,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":19,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":20,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":21,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sand"}}} +{"type":"assistant/chunk","seq":22,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"box"}}} +{"type":"assistant/chunk","seq":23,"time":1784045703252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_per"}}} +{"type":"assistant/chunk","seq":24,"time":1784045703277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"missions"}}} +{"type":"assistant/chunk","seq":25,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":26,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":27,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":28,"time":1784045703278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":29,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":30,"time":1784045703304,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1784045703356,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1784045703381,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":36,"time":1784045703405,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":37,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":39,"time":1784045703406,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1784045703431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":41,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":42,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":43,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":".md"}}} +{"type":"assistant/chunk","seq":44,"time":1784045703432,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":46,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":48,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1784045703483,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":50,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"es"}}} +{"type":"assistant/chunk","seq":52,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"cal"}}} +{"type":"assistant/chunk","seq":53,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ated"}}} +{"type":"assistant/chunk","seq":54,"time":1784045703509,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1784045703565,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":56,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"sand"}}} +{"type":"assistant/chunk","seq":58,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"box"}}} +{"type":"assistant/chunk","seq":59,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"_per"}}} +{"type":"assistant/chunk","seq":60,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"missions"}}} +{"type":"assistant/chunk","seq":61,"time":1784045703566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"danger"}}} +{"type":"assistant/chunk","seq":65,"time":1784045703591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-full"}}} +{"type":"assistant/chunk","seq":66,"time":1784045703617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"-access"}}} +{"type":"assistant/chunk","seq":67,"time":1784045703618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":68,"time":1784045703644,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":69,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"just"}}} +{"type":"assistant/chunk","seq":71,"time":1784045703645,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"ification"}}} +{"type":"assistant/chunk","seq":72,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":74,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1784045703669,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"the"}}} +{"type":"assistant/chunk","seq":76,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" user"}}} +{"type":"assistant/chunk","seq":77,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" asked"}}} +{"type":"assistant/chunk","seq":78,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":79,"time":1784045703696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" escalate"}}} +{"type":"assistant/chunk","seq":80,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":81,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":" write"}}} +{"type":"assistant/chunk","seq":82,"time":1784045703724,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":83,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":84,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"type":"assistant/chunk","seq":85,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}}} +{"type":"assistant/chunk","seq":86,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} +{"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"5b715180-e0eb-4ab6-98ff-965fd9c6f08b","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"5b715180-e0eb-4ab6-98ff-965fd9c6f08b","outcome":"allowed-once"}} +{"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} +{"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":95,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":96,"time":1784045704512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":97,"time":1784045704620,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":98,"time":1784045704645,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":99,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":100,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":101,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":102,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":103,"time":1784045704646,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":104,"time":1784045704672,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":105,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":106,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":107,"time":1784045704673,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":108,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":109,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":110,"time":1784045704699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":111,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":112,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":113,"time":1784045704726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":114,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":115,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":116,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":117,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":118,"time":1784045704754,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":120,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":122,"time":1784045704755,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1784045704755,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":107,"outputTokens":23,"cacheReadTokens":3968,"reasoningTokens":20}},"sourceEventSeqs":[95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1784045704755,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1784045704756,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl new file mode 100644 index 0000000000..3e86a1d4e8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl @@ -0,0 +1,52 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sand"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"box"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_per"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"missions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","title":"Write escalated.md","kind":"edit","status":"in_progress","locations":[{"path":"escalated.md"}],"content":[{"type":"diff","path":"escalated.md","oldText":null,"newText":"escalated"}]}}} +{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","status":"completed","content":[{"type":"diff","path":"escalated.md","oldText":null,"newText":"escalated"}],"title":"Write escalated.md"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} From 6af61c6f4e89dedeec5b60886eed16144a37cf10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 15 Jul 2026 18:08:28 +0800 Subject: [PATCH 011/132] fix(docs): align site with bilingual source pairs --- .agents/skills/dsh-doc-site-sync/SKILL.md | 6 +- docs/AGENTS.md | 2 +- docs/user/develop/basic/config.i18n.yaml | 6 + docs/user/develop/basic/config.md | 111 ++++++++++ .../config.md => develop/basic/config.zh.md} | 20 +- docs/user/develop/basic/index.i18n.yaml | 6 + docs/user/develop/basic/index.md | 151 +++++++++++++ .../index.md => develop/basic/index.zh.md} | 36 +-- docs/user/develop/basic/tool.i18n.yaml | 6 + docs/user/develop/basic/tool.md | 208 ++++++++++++++++++ .../tool.md => develop/basic/tool.zh.md} | 69 +++--- docs/user/develop/framework/events.i18n.yaml | 6 + docs/user/develop/framework/events.md | 143 ++++++++++++ .../framework/events.zh.md} | 47 ++-- docs/user/develop/framework/index.i18n.yaml | 6 + docs/user/develop/framework/index.md | 131 +++++++++++ .../framework/index.zh.md} | 26 ++- docs/user/develop/framework/service.i18n.yaml | 6 + docs/user/develop/framework/service.md | 148 +++++++++++++ .../framework/service.zh.md} | 34 +-- docs/user/develop/practice/index.i18n.yaml | 6 + docs/user/develop/practice/index.md | 158 +++++++++++++ .../index.md => develop/practice/index.zh.md} | 18 +- .../develop/practice/llm-adapter.i18n.yaml | 6 + docs/user/develop/practice/llm-adapter.md | 185 ++++++++++++++++ docs/user/develop/practice/llm-adapter.zh.md | 185 ++++++++++++++++ docs/user/guide/config.i18n.yaml | 6 + docs/user/guide/config.md | 59 +++++ .../guide/config.md => guide/config.zh.md} | 10 +- docs/user/guide/index.i18n.yaml | 6 + docs/user/guide/index.md | 49 +++++ .../guide/index.md => guide/index.zh.md} | 6 +- docs/user/guide/quickstart.i18n.yaml | 6 + docs/user/guide/quickstart.md | 99 +++++++++ .../quickstart.md => guide/quickstart.zh.md} | 14 +- docs/user/index.i18n.yaml | 6 + docs/user/index.md | 25 +++ docs/user/{zh-CN/index.md => index.zh.md} | 4 + .../zh-CN/develop/practice/llm-adapter.md | 174 --------------- scripts/project-doc-site.spec.ts | 54 ++++- scripts/project-doc-site.ts | 99 ++++++++- scripts/translation-pairing.manifest.json | 12 + website/docs.ts | 110 +++++---- 43 files changed, 2112 insertions(+), 353 deletions(-) create mode 100644 docs/user/develop/basic/config.i18n.yaml create mode 100644 docs/user/develop/basic/config.md rename docs/user/{zh-CN/develop/basic/config.md => develop/basic/config.zh.md} (90%) create mode 100644 docs/user/develop/basic/index.i18n.yaml create mode 100644 docs/user/develop/basic/index.md rename docs/user/{zh-CN/develop/basic/index.md => develop/basic/index.zh.md} (83%) create mode 100644 docs/user/develop/basic/tool.i18n.yaml create mode 100644 docs/user/develop/basic/tool.md rename docs/user/{zh-CN/develop/basic/tool.md => develop/basic/tool.zh.md} (80%) create mode 100644 docs/user/develop/framework/events.i18n.yaml create mode 100644 docs/user/develop/framework/events.md rename docs/user/{zh-CN/develop/framework/events.md => develop/framework/events.zh.md} (83%) create mode 100644 docs/user/develop/framework/index.i18n.yaml create mode 100644 docs/user/develop/framework/index.md rename docs/user/{zh-CN/develop/framework/index.md => develop/framework/index.zh.md} (80%) create mode 100644 docs/user/develop/framework/service.i18n.yaml create mode 100644 docs/user/develop/framework/service.md rename docs/user/{zh-CN/develop/framework/service.md => develop/framework/service.zh.md} (80%) create mode 100644 docs/user/develop/practice/index.i18n.yaml create mode 100644 docs/user/develop/practice/index.md rename docs/user/{zh-CN/develop/practice/index.md => develop/practice/index.zh.md} (94%) create mode 100644 docs/user/develop/practice/llm-adapter.i18n.yaml create mode 100644 docs/user/develop/practice/llm-adapter.md create mode 100644 docs/user/develop/practice/llm-adapter.zh.md create mode 100644 docs/user/guide/config.i18n.yaml create mode 100644 docs/user/guide/config.md rename docs/user/{zh-CN/guide/config.md => guide/config.zh.md} (71%) create mode 100644 docs/user/guide/index.i18n.yaml create mode 100644 docs/user/guide/index.md rename docs/user/{zh-CN/guide/index.md => guide/index.zh.md} (94%) create mode 100644 docs/user/guide/quickstart.i18n.yaml create mode 100644 docs/user/guide/quickstart.md rename docs/user/{zh-CN/guide/quickstart.md => guide/quickstart.zh.md} (88%) create mode 100644 docs/user/index.i18n.yaml create mode 100644 docs/user/index.md rename docs/user/{zh-CN/index.md => index.zh.md} (93%) delete mode 100644 docs/user/zh-CN/develop/practice/llm-adapter.md diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 0fdd9b9398..bee1b0dfe8 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -7,6 +7,8 @@ description: Use when publishing, updating, moving, or removing DeepSeek Harness Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. +Repository translations follow the sibling pairing contract: English `foo.md`, Chinese `foo.zh.md`, and `foo.i18n.yaml` live together. Never create `zh-CN/` or other locale directories for website content. The site route trees are independent of that source layout: `foo.zh.md` projects to the root route and `foo.md` projects to the matching `/en/` route. + ## Read the owning contracts - Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. @@ -28,7 +30,7 @@ Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist Set every `DocsPage` field deliberately: -- `source`: repository-relative canonical Markdown path. +- `source`: repository-relative canonical Markdown path. For a complete bilingual pair, add the English `.md` path through `pairedPages()`; it derives the sibling `.zh.md`, the content locales, and counterpart aliases. - `route`: public VitePress path including the `.md` suffix. - `label`: sidebar label, not necessarily the document H1. - `sidebar`: reuse `zh-guide`, `zh-develop`, or `en-docs` unless the information architecture genuinely needs another collection. @@ -36,7 +38,7 @@ Set every `DocsPage` field deliberately: - `order`: stable order within the section. - `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. -Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. +Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. ## Preserve link behavior diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 42f46b64e4..2723434e64 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,7 +15,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | [rfc/](rfc/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` RFCs describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | -| [user/](user/zh-CN/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | +| [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml new file mode 100644 index 0000000000..e4b71a7353 --- /dev/null +++ b/docs/user/develop/basic/config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +config.md: 5c4e712e2ae452d30fde23bd2481f0c5260ee526 +config.zh.md: 23c97e18a119a92fe7ae883621cb9340ef54bf80 diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md new file mode 100644 index 0000000000..5c4e712e2a --- /dev/null +++ b/docs/user/develop/basic/config.md @@ -0,0 +1,111 @@ +# Plugin configuration + +English | [中文](config.zh.md) + +Accept configuration supplied through `cordis.yml`. + +## Define the Config type + +Export a `Config` type and a same-named Schemastery schema. Put defaults directly on the schema fields: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'my-plugin' + +export interface Config { + greeting: string + maxRetries: number + verbose?: boolean +} + +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + maxRetries: Schema.number().default(3), + verbose: Schema.boolean().default(false), +}) + +export function apply(ctx: Context, config: Config) { + console.log(config.greeting) // User value or schema default. +} +``` + +Configure it in `cordis.yml`: + +```yaml +- name: './src/my-plugin.ts' + config: + greeting: 'Hi there' + maxRetries: 5 +``` + +When loading the plugin, Cordis uses the exported schema to validate configuration and fill defaults. Do not export a plain object as `Config`; it does not implement the Standard Schema interface required by Cordis. + +## Schema validation + +Use Schemastery to express stricter validation: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'validated-plugin' + +export interface Config { + apiKey: string + timeout: number + mode: 'fast' | 'accurate' +} + +export const Config = Schema.object({ + apiKey: Schema.string().required(), + timeout: Schema.number().default(30000), + mode: Schema.union(['fast', 'accurate']).default('fast'), +}) + +export function apply(ctx: Context, config: Config) { + // config is validated and type-safe. +} +``` + +The schema runs while the plugin loads. Invalid configuration fails the load with an actionable error. + +## Design principles + +### Do not hardcode tunable values + +Harness requires **anything that two deployments may want to set differently to be a configuration field**. + +```ts +// Wrong: hardcoded timeout. +const TIMEOUT = 30000 + +// Correct: configurable. +export interface Config { + timeoutMs: number // Defaults to 30000. +} +``` + +The test is whether `cordis.yml` can change the value without a code edit. + +### Fail loudly on invalid configuration + +If configuration refers to a missing model or another nonexistent resource, fail early instead of silently skipping it: + +```ts ignore-check +export function apply(ctx: Context, config: Config) { + if (!ctx.llm.models().includes(config.model)) { + throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) + } +} +``` + +## Work with HMR + +A configuration edit hot-replaces the plugin: the framework unloads the old instance and loads a new one. Because registrations are effects and clean themselves up, replacement does not retain the old instance's registrations. + +## Next steps + +- [Plugins and lifecycle](../framework/) — understand the full plugin lifecycle +- [Services and dependencies](../framework/service.md) — provide a service to other plugins diff --git a/docs/user/zh-CN/develop/basic/config.md b/docs/user/develop/basic/config.zh.md similarity index 90% rename from docs/user/zh-CN/develop/basic/config.md rename to docs/user/develop/basic/config.zh.md index 23294f7955..23c97e18a1 100644 --- a/docs/user/zh-CN/develop/basic/config.md +++ b/docs/user/develop/basic/config.zh.md @@ -1,12 +1,14 @@ # 插件配置 +[English](config.md) | 中文 + 让你的插件接受用户在 `cordis.yml` 中传入的配置。 ## 定义 Config 类型 在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: -```typescript +```ts import type { Context } from 'cordis' import Schema from 'schemastery' @@ -25,7 +27,7 @@ export const Config: Schema = Schema.object({ }) export function apply(ctx: Context, config: Config) { - console.log(config.greeting) // 用户配置或默认值 + console.log(config.greeting) // User value or schema default. } ``` @@ -44,7 +46,7 @@ export function apply(ctx: Context, config: Config) { 对于需要严格校验的场景,使用 Schemastery 定义 schema: -```typescript +```ts import type { Context } from 'cordis' import Schema from 'schemastery' @@ -63,7 +65,7 @@ export const Config = Schema.object({ }) export function apply(ctx: Context, config: Config) { - // config 已经过校验,类型安全 + // config is validated and type-safe. } ``` @@ -75,13 +77,13 @@ Schema 在插件加载时执行校验。如果配置不合法,插件会加载 Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。 -```typescript -// 错误 — 硬编码超时时间 +```ts +// Wrong: hardcoded timeout. const TIMEOUT = 30000 -// 正确 — 可配置 +// Correct: configurable. export interface Config { - timeoutMs: number // 默认 30000 + timeoutMs: number // Defaults to 30000. } ``` @@ -91,7 +93,7 @@ export interface Config { 如果配置引用了不存在的东西(比如一个不存在的模型名),应该尽早报错,而不是静默跳过: -```typescript +```ts ignore-check export function apply(ctx: Context, config: Config) { if (!ctx.llm.models().includes(config.model)) { throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml new file mode 100644 index 0000000000..22b03af93e --- /dev/null +++ b/docs/user/develop/basic/index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +index.md: 5fa46806bc195ad2566fc0a29b45eb1dd7a68179 +index.zh.md: a6d238c12841c8c25b00376ee032e5db50fc6b4e diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md new file mode 100644 index 0000000000..5fa46806bc --- /dev/null +++ b/docs/user/develop/basic/index.md @@ -0,0 +1,151 @@ +# Your first plugin + +English | [中文](index.zh.md) + +This guide creates a minimal Harness plugin and loads it into an agent. + +## What is a plugin? + +In Harness, a plugin is a TypeScript module that exports an `apply` function. The framework calls `apply` when loading the plugin and passes a `ctx` context object through which the plugin registers capabilities: + +```ts +import type { Context } from 'cordis' + +export const name = 'my-plugin' + +export function apply(ctx: Context) { + // Register capabilities here. +} +``` + +That is the complete shape. + +## Create the plugin file + +Create `src/my-plugin.ts` in your project: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello-plugin' + +export function apply(ctx: Context) { + // Required dependencies are ready before apply runs. + console.log('[hello-plugin] plugin loaded!') +} +``` + +## Register it in cordis.yml + +Add an entry to `cordis.yml`: + +```yaml +- id: hello + name: './src/my-plugin.ts' +``` + +After startup, the console prints `[hello-plugin] plugin loaded!`. + +## Automatic cleanup + +Anything registered through `ctx`—event listeners, tools, or timers—is cleaned up when the plugin unloads. You do not need to call removeListener or clearInterval manually. + +For a resource that needs explicit cleanup, such as a network connection, use `ctx.effect()` to provide its disposer: + +```ts +import type { Context } from 'cordis' + +export function apply(ctx: Context) { + ctx.effect(() => { + const timer = setInterval(() => { + console.log('heartbeat') + }, 5000) + + // The returned function runs when the plugin unloads. + return () => clearInterval(timer) + }) +} +``` + +## Declare dependencies + +If the plugin consumes another service such as `tools` or `llm`, declare it in `inject`: + +```ts ignore-check +import type { Context } from 'cordis' + +export const name = 'my-tool-plugin' +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools is ready here. + ctx.tools.register(/* ... */) +} +``` + +The framework waits for every required service before loading the plugin. + +## Three plugin forms + +In addition to a function module, a plugin can use object or class form. + +### Object form + +```ts +import type { Context } from 'cordis' + +export default { + name: 'my-plugin', + inject: ['tools'], + apply(ctx: Context) { + // ... + }, +} +``` + +### Class form + +```ts +import { Service, type Context } from 'cordis' + +export default class MyService extends Service { + static inject = ['tools'] + + constructor(ctx: Context) { + super(ctx, 'myService') + // Perform synchronous initialization in the constructor. + } +} +``` + +Function form is sufficient in most cases. Use class form when the plugin provides a service to other plugins; see [services and dependencies](../framework/service.md). + +## Complete example + +`examples/echo-agent/src/echo-tool.ts` is a plugin that registers a tool: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'echo-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo the given text back, uppercased.', + parameters: { + text: { type: 'string', required: true }, + }, + async execute(args) { + return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + }, + })) +} +``` + +## Next steps + +- [Build a tool](./tool.md) — learn the tool definition DSL +- [Plugin configuration](./config.md) — accept user configuration diff --git a/docs/user/zh-CN/develop/basic/index.md b/docs/user/develop/basic/index.zh.md similarity index 83% rename from docs/user/zh-CN/develop/basic/index.md rename to docs/user/develop/basic/index.zh.md index c1f7ab800b..a6d238c128 100644 --- a/docs/user/zh-CN/develop/basic/index.md +++ b/docs/user/develop/basic/index.zh.md @@ -1,18 +1,20 @@ # 第一个插件 +[English](index.md) | 中文 + 本文带你编写一个最小的 Harness 插件并加载到 Agent 中。 ## 插件是什么 在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: -```typescript +```ts import type { Context } from 'cordis' export const name = 'my-plugin' export function apply(ctx: Context) { - // 在这里注册能力 + // Register capabilities here. } ``` @@ -22,14 +24,14 @@ export function apply(ctx: Context) { 在你的项目目录下创建 `src/my-plugin.ts`: -```typescript +```ts import type { Context } from 'cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { - // apply 被调用时,插件的必选依赖已就绪 - console.log('[hello-plugin] 插件已加载!') + // Required dependencies are ready before apply runs. + console.log('[hello-plugin] plugin loaded!') } ``` @@ -42,7 +44,7 @@ export function apply(ctx: Context) { name: './src/my-plugin.ts' ``` -启动后你会在控制台看到 `[hello-plugin] 插件已加载!`。 +启动后你会在控制台看到 `[hello-plugin] plugin loaded!`。 ## 自动清理 @@ -50,14 +52,16 @@ export function apply(ctx: Context) { 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: -```typescript +```ts +import type { Context } from 'cordis' + export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => { console.log('heartbeat') }, 5000) - // 返回的函数会在插件卸载时被调用 + // The returned function runs when the plugin unloads. return () => clearInterval(timer) }) } @@ -67,12 +71,14 @@ export function apply(ctx: Context) { 如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: -```typescript +```ts ignore-check +import type { Context } from 'cordis' + export const name = 'my-tool-plugin' export const inject = ['tools'] export function apply(ctx: Context) { - // ctx.tools 现在可用 + // ctx.tools is ready here. ctx.tools.register(/* ... */) } ``` @@ -85,7 +91,9 @@ export function apply(ctx: Context) { ### 对象形式 -```typescript +```ts +import type { Context } from 'cordis' + export default { name: 'my-plugin', inject: ['tools'], @@ -97,7 +105,7 @@ export default { ### 类形式 -```typescript +```ts import { Service, type Context } from 'cordis' export default class MyService extends Service { @@ -105,7 +113,7 @@ export default class MyService extends Service { constructor(ctx: Context) { super(ctx, 'myService') - // 构造函数内完成同步初始化 + // Perform synchronous initialization in the constructor. } } ``` @@ -116,7 +124,7 @@ export default class MyService extends Service { 参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml new file mode 100644 index 0000000000..d2f4343cf1 --- /dev/null +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +tool.md: 416733bcb584fa5303a8b3ba5e6e904302e7f992 +tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md new file mode 100644 index 0000000000..416733bcb5 --- /dev/null +++ b/docs/user/develop/basic/tool.md @@ -0,0 +1,208 @@ +# Build a tool + +English | [中文](tool.zh.md) + +A tool is a capability the model can call. This guide builds one with `defineTool`. + +## Minimal example + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'my-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'greet', + description: 'Greet someone by name.', + parameters: { + name: { type: 'string', required: true, description: 'The name to greet' }, + }, + async execute(args) { + // args is inferred as { name: string }. + return [{ type: 'text', text: `Hello, ${args.name}!` }] + }, + })) +} +``` + +## Parameter definitions + +`parameters` uses a compact format that the framework converts to the JSON Schema sent to the model. + +### Primitive types + +```ts +export const parameters = { + path: { type: 'string', required: true }, + limit: { type: 'number' }, + recursive: { type: 'boolean' }, +} +// Inferred type: { path: string; limit?: number; recursive?: boolean } +``` + +### Enums + +```ts +export const parameters = { + mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, +} +// Inferred type: { mode: string } (enum values are validated at runtime) +``` + +### Nested objects + +```ts +export const parameters = { + options: { + type: 'object', + properties: { + timeout: { type: 'number' }, + retries: { type: 'number' }, + }, + }, +} +// Inferred type: { options?: { timeout?: number; retries?: number } } +``` + +### Arrays + +```ts +export const parameters = { + tags: { + type: 'array', + items: { type: 'string' }, + }, +} +// Inferred type: { tags?: string[] } +``` + +### Property fields + +| Field | Type | Meaning | +|------|------|------| +| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | Value type | +| `required` | `true` | Marks the property required and affects inference | +| `description` | `string` | Description sent to the model | +| `enum` | `string[]` | Allowed string values | +| `properties` | `SchemaSpec` | Nested properties for an object | +| `items` | `SchemaProp` | Element schema for an array | + +## The execute function + +`execute` receives validated, inferred `args` and an `exec` execution context: + +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const tool = defineTool({ + name: 'example', + description: 'Return an example result.', + parameters: {}, + async execute(args, exec) { + // args: inferred from parameters + // exec: ToolExecution context + + // Return a ContentBlock array. + void args + void exec + return [{ type: 'text', text: 'result here' }] + }, +}) +``` + +### Return value + +`execute` returns a `ContentBlock[]` that becomes the tool result visible to the model: + +```ts ignore-check +// Text result +return [{ type: 'text', text: 'file content here...' }] + +// Multiple blocks +return [ + { type: 'text', text: 'Found 3 matches:' }, + { type: 'text', text: matchResults.join('\n') }, +] +``` + +### Argument validation + +Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call. + +Do not repeat type validation inside `execute`. + +## Presentation + +A tool can define UI presentation methods for terminal and ACP clients: + +```ts ignore-check +defineTool({ + name: 'bash', + // ... + presentCall(args) { + return { + card: 'terminal', + title: args.command, + } + }, + presentResult(args, result) { + return { + card: 'terminal', + output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + } + }, +}) +``` + +`presentCall` and `presentResult` are **pure functions**. Streaming UI and session replay may call them more than once. + +## Registration and unloading + +`ctx.tools.register()` returns a disposer, but a registration made through `ctx` is already tracked by the framework. Unloading the plugin removes the tool automatically, so the plugin does not call the disposer itself. + +```ts ignore-check +// This is sufficient: +ctx.tools.register(defineTool({ /* ... */ })) + +// No saved disposer or extra cleanup registration is needed. +``` + +## Complete example + +This tool counts files in a directory: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { readdir } from 'node:fs/promises' + +export const name = 'file-counter' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'count_files', + description: 'Count files in a directory.', + parameters: { + path: { type: 'string', required: true, description: 'Directory path' }, + extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, + }, + async execute(args) { + const entries = await readdir(args.path, { withFileTypes: true }) + let files = entries.filter(e => e.isFile()) + if (args.extension) { + files = files.filter(f => f.name.endsWith(args.extension!)) + } + return [{ type: 'text', text: `Found ${files.length} files.` }] + }, + })) +} +``` + +## Next steps + +- [Plugin configuration](./config.md) — make the tool configurable +- [Capability layering](../practice/) — understand the interface/implementation/consumer pattern diff --git a/docs/user/zh-CN/develop/basic/tool.md b/docs/user/develop/basic/tool.zh.md similarity index 80% rename from docs/user/zh-CN/develop/basic/tool.md rename to docs/user/develop/basic/tool.zh.md index 9eb4715385..fce9a7d9b9 100644 --- a/docs/user/zh-CN/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.zh.md @@ -1,10 +1,12 @@ # 开发一个 Tool +[English](tool.md) | 中文 + Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个 tool。 ## 最小示例 -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -19,7 +21,7 @@ export function apply(ctx: Context) { name: { type: 'string', required: true, description: 'The name to greet' }, }, async execute(args) { - // args 自动推导为 { name: string } + // args is inferred as { name: string }. return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) @@ -32,28 +34,28 @@ export function apply(ctx: Context) { ### 基本类型 -```typescript -parameters: { +```ts +export const parameters = { path: { type: 'string', required: true }, limit: { type: 'number' }, recursive: { type: 'boolean' }, } -// 推导类型: { path: string; limit?: number; recursive?: boolean } +// Inferred type: { path: string; limit?: number; recursive?: boolean } ``` ### 枚举 -```typescript -parameters: { +```ts +export const parameters = { mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, } -// 推导类型: { mode: string } (运行时校验 enum 值) +// Inferred type: { mode: string } (enum values are validated at runtime) ``` ### 嵌套对象 -```typescript -parameters: { +```ts +export const parameters = { options: { type: 'object', properties: { @@ -62,19 +64,19 @@ parameters: { }, }, } -// 推导类型: { options?: { timeout?: number; retries?: number } } +// Inferred type: { options?: { timeout?: number; retries?: number } } ``` ### 数组 -```typescript -parameters: { +```ts +export const parameters = { tags: { type: 'array', items: { type: 'string' }, }, } -// 推导类型: { tags?: string[] } +// Inferred type: { tags?: string[] } ``` ### 每个属性的字段 @@ -92,25 +94,34 @@ parameters: { `execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象: -```typescript -async execute(args, exec) { - // args: 根据 parameters 自动推导的类型 - // exec: ToolExecution 对象,提供执行上下文 +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' - // 返回 ContentBlock 数组 - return [{ type: 'text', text: 'result here' }] -} +export const tool = defineTool({ + name: 'example', + description: 'Return an example result.', + parameters: {}, + async execute(args, exec) { + // args: inferred from parameters + // exec: ToolExecution context + + // Return a ContentBlock array. + void args + void exec + return [{ type: 'text', text: 'result here' }] + }, +}) ``` ### 返回值 `execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: -```typescript -// 文本结果 +```ts ignore-check +// Text result return [{ type: 'text', text: 'file content here...' }] -// 多个 block +// Multiple blocks return [ { type: 'text', text: 'Found 3 matches:' }, { type: 'text', text: matchResults.join('\n') }, @@ -127,7 +138,7 @@ return [ Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 tool call 和 result: -```typescript +```ts ignore-check defineTool({ name: 'bash', // ... @@ -152,18 +163,18 @@ defineTool({ `ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。 -```typescript -// 这样就够了: +```ts ignore-check +// This is sufficient: ctx.tools.register(defineTool({ /* ... */ })) -// 不需要额外保存 disposer 或注册清理逻辑 +// No saved disposer or extra cleanup registration is needed. ``` ## 完整实战示例 一个文件计数 tool: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { readdir } from 'node:fs/promises' diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml new file mode 100644 index 0000000000..9704eff7c5 --- /dev/null +++ b/docs/user/develop/framework/events.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +events.md: 0c57681a55ea0200fe8f33293176fc94f09a4ce5 +events.zh.md: 3e14739d4a97ba014d545c9f226000507aaeacef diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md new file mode 100644 index 0000000000..0c57681a55 --- /dev/null +++ b/docs/user/develop/framework/events.md @@ -0,0 +1,143 @@ +# Event system + +English | [中文](events.zh.md) + +Events are the core communication mechanism between Cordis plugins. Harness uses them extensively for loosely coupled extension points. + +## Basic use + +### Listen for an event + +```ts ignore-check +ctx.on('event-name', (payload) => { + // Handle the event. +}) +``` + +### Emit an event + +```ts ignore-check +ctx.emit('event-name', payload) +``` + +## Event modes + +Cordis provides several event modes for different interaction contracts. + +### emit — broadcast + +Every listener runs synchronously and return values are ignored: + +```ts ignore-check +// Emit +ctx.emit('my-plugin/ready', { id: 'worker-1' }) + +// Listen +ctx.on('my-plugin/ready', ({ id }) => { + console.log(`${id} is ready`) +}) +``` + +### bail — short circuit + +Listeners run in order; the first non-`undefined` result becomes the final result: + +```ts ignore-check +// Dispatch +const result = ctx.bail('some-check', input) + +// Listen: a returned value stops later listeners. +ctx.on('some-check', (input) => { + if (shouldBlock(input)) return 'blocked' + // Return undefined to continue to the next listener. +}) +``` + +### serial — ordered execution + +Listeners run in registration order and asynchronous results are awaited. The first listener to return a non-empty value stops further execution: + +```ts ignore-check +await ctx.serial('setup-phase', context) +``` + +### waterfall — pipeline + +Each listener may wrap the downstream result to form a processing chain. A listener **must call `next()` to delegate downstream**; omitting the call vetoes the pipeline: + +```ts ignore-check +// Dispatch +const output = await ctx.waterfall('my-plugin/transform', input, async () => input) + +// Listen: next() is mandatory. +ctx.on('my-plugin/transform', async (_input, next) => { + const downstream = await next() + return downstream.trim() +}) +``` + +::: warning +A waterfall listener **must call `next()`**. Omitting it vetoes the pipeline by design, enabling interception and gateway behavior. +::: + +## Typed events + +Harness uses TypeScript declaration merging for type-safe events: + +```ts +import 'cordis' + +declare module 'cordis' { + interface Events { + 'my-plugin/ready': (payload: { id: string }) => void + 'my-plugin/check': (input: string) => boolean | undefined + 'my-plugin/transform': (input: string, next: () => Promise) => Promise + } +} + +// ctx.on('my-plugin/ready', ...) and ctx.emit('my-plugin/ready', ...) +// are now inferred correctly. +``` + +## Cordis events and session records + +Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. + +`turn/*`, `step/*`, `tool/call`, `tool/result`, and `compact/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. + +## Event listeners are effects + +A listener registered with `ctx.on()` is removed automatically when its plugin unloads: + +```ts ignore-check +export function apply(ctx: Context) { + // This listener is removed when the plugin disposes. + ctx.on('tools/result', handler) +} +``` + +## Example: logging plugin + +This plugin logs tool calls and results: + +```ts +import type { Context } from 'cordis' +import '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`) + const text = result.content + .map(block => block.type === 'text' ? block.text : '') + .join('') + console.log(`[tool result] ${text.slice(0, 100)}`) + }) +} +``` + +## Next steps + +- [Capability layering](../practice/) — understand events within capability interfaces +- [LLM adapters](../practice/llm-adapter.md) — implement a complete LLM backend diff --git a/docs/user/zh-CN/develop/framework/events.md b/docs/user/develop/framework/events.zh.md similarity index 83% rename from docs/user/zh-CN/develop/framework/events.md rename to docs/user/develop/framework/events.zh.md index 80f49d38dc..3e14739d4a 100644 --- a/docs/user/zh-CN/develop/framework/events.md +++ b/docs/user/develop/framework/events.zh.md @@ -1,20 +1,22 @@ # 事件系统 +[English](events.md) | 中文 + 事件是 Cordis 插件间通信的核心机制。Harness 大量使用事件来实现松耦合的扩展点。 ## 基本用法 ### 监听事件 -```typescript +```ts ignore-check ctx.on('event-name', (payload) => { - // 处理事件 + // Handle the event. }) ``` ### 触发事件 -```typescript +```ts ignore-check ctx.emit('event-name', payload) ``` @@ -26,11 +28,11 @@ Cordis 提供多种事件触发模式,适用于不同场景: 所有监听器同步执行,不关心返回值: -```typescript -// 触发 +```ts ignore-check +// Emit ctx.emit('my-plugin/ready', { id: 'worker-1' }) -// 监听 +// Listen ctx.on('my-plugin/ready', ({ id }) => { console.log(`${id} is ready`) }) @@ -40,14 +42,14 @@ ctx.on('my-plugin/ready', ({ id }) => { 依次调用监听器,第一个返回非 `undefined` 值的结果作为最终值: -```typescript -// 触发 +```ts ignore-check +// Dispatch const result = ctx.bail('some-check', input) -// 监听(返回值阻止后续监听器) +// Listen: a returned value stops later listeners. ctx.on('some-check', (input) => { if (shouldBlock(input)) return 'blocked' - // 返回 undefined 继续传递给下一个监听器 + // Return undefined to continue to the next listener. }) ``` @@ -55,7 +57,7 @@ ctx.on('some-check', (input) => { 监听器按注册顺序依次执行,并等待异步结果;第一个返回非空值的监听器会终止后续执行: -```typescript +```ts ignore-check await ctx.serial('setup-phase', context) ``` @@ -63,11 +65,11 @@ await ctx.serial('setup-phase', context) 每个监听器可以包装下游返回值,形成处理链。**必须调用 `next()` 传递给下游**,不调用即为否决: -```typescript -// 触发 +```ts ignore-check +// Dispatch const output = await ctx.waterfall('my-plugin/transform', input, async () => input) -// 监听(必须调用 next) +// Listen: next() is mandatory. ctx.on('my-plugin/transform', async (_input, next) => { const downstream = await next() return downstream.trim() @@ -82,7 +84,9 @@ Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整 Harness 使用 TypeScript 声明合并来为事件提供类型安全: -```typescript +```ts +import 'cordis' + declare module 'cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void @@ -91,13 +95,13 @@ declare module 'cordis' { } } -// 现在 ctx.on('my-plugin/ready', ...) 和 ctx.emit('my-plugin/ready', ...) -// 都有正确的类型推导 +// ctx.on('my-plugin/ready', ...) and ctx.emit('my-plugin/ready', ...) +// are now inferred correctly. ``` ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../../cordis-catalog/events.md)。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 @@ -105,9 +109,9 @@ Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-st 通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 这个监听器在插件 dispose 时自动清理 + // This listener is removed when the plugin disposes. ctx.on('tools/result', handler) } ``` @@ -116,8 +120,9 @@ export function apply(ctx: Context) { 一个记录所有 tool 调用的简单插件: -```typescript +```ts import type { Context } from 'cordis' +import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml new file mode 100644 index 0000000000..79c947dcb9 --- /dev/null +++ b/docs/user/develop/framework/index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +index.md: bb08e7cb3f9d3a094100806451fd33d1482003cb +index.zh.md: 4b9d6d22c8d82bece67c71032a0028b21939f98f diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md new file mode 100644 index 0000000000..bb08e7cb3f --- /dev/null +++ b/docs/user/develop/framework/index.md @@ -0,0 +1,131 @@ +# Plugins and lifecycle + +English | [中文](index.zh.md) + +This page describes the Cordis plugin model and lifecycle state machine. + +## Fiber state machine + +Every loaded plugin owns a **Fiber** scope with the following states: + +``` +PENDING → LOADING → ACTIVE + ↘ FAILED +ACTIVE → UNLOADING → DISPOSED +``` + +| State | Meaning | +|------|------| +| PENDING | Declared, but required dependencies are not ready | +| LOADING | Dependencies are ready and `apply` is running | +| ACTIVE | The plugin is running | +| FAILED | `apply` threw an error | +| UNLOADING | The plugin is unloading and disposing resources | +| DISPOSED | The plugin is fully unloaded | + +## Dependency-driven loading + +A plugin with `inject` waits for every required service before loading: + +```ts ignore-check +export const inject = ['tools', 'llm'] + +export function apply(ctx: Context) { + // ctx.tools and ctx.llm are ready here. +} +``` + +If a required service disappears, for example during provider replacement, the plugin unloads automatically (ACTIVE → DISPOSED) and loads again when the service returns. + +## Automatic cleanup + +Every registration made through `ctx` is undone when the plugin unloads: + +```ts ignore-check +export function apply(ctx: Context) { + // Event listener: removed automatically on unload. + ctx.on('some-event', handler) + + // Custom resource: the returned disposer runs on unload. + ctx.effect(() => { + const connection = createConnection() + return () => connection.close() + }) +} +``` + +The framework tracks and disposes all of these operations: +- `ctx.on(event, handler)` — event listener +- `ctx.tools.register(tool)` — tool registration +- `ctx.llm.registerAdapter(names, adapter)` — LLM adapter registration +- `ctx.effect(() => cleanup)` — custom resource + +During unload, disposer invocation starts in reverse registration order, but multiple async disposers run concurrently and have no serial completion guarantee. Put order-dependent cleanup in one disposer returned from a single `ctx.effect()` and await its steps serially there. + +## Nested contexts + +`ctx.plugin()` creates a child Fiber that inherits the parent context but has an independent lifecycle: + +```ts ignore-check +export function apply(ctx: Context) { + // Register a child plugin. + ctx.plugin(childPlugin) + + // The child has its own Fiber and unloads with its parent. +} +``` + +## Dispose semantics + +To stop a plugin instance early: + +```ts ignore-check +const fiber = ctx.plugin(myPlugin) + +// Dispose it manually later. +fiber.dispose() +``` + +`dispose` guarantees: +1. All registrations owned by the plugin are removed. +2. Child plugins are recursively unloaded. +3. The returned promise resolves after all asynchronous cleanup finishes. + +## Hot replacement (HMR) + +With `@cordisjs/plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: + +1. Unload the old plugin and clean up its registrations. +2. Load the new code. +3. Run the new `apply`. + +Because plugin registrations clean themselves up, hot replacement does not retain registrations from the old instance. + +## Example lifecycle + +```ts ignore-check +export function apply(ctx: Context) { + console.log('plugin loading') + + ctx.effect(() => { + console.log('effect registered') + return () => console.log('effect cleaned up') + }) +} +``` + +Loading prints: +``` +plugin loading +effect registered +``` + +Unloading prints: +``` +effect cleaned up +``` + +## Next steps + +- [Services and dependencies](./service.md) — expose a capability to other plugins +- [Event system](./events.md) — communicate between plugins diff --git a/docs/user/zh-CN/develop/framework/index.md b/docs/user/develop/framework/index.zh.md similarity index 80% rename from docs/user/zh-CN/develop/framework/index.md rename to docs/user/develop/framework/index.zh.md index a3fdd502b5..4b9d6d22c8 100644 --- a/docs/user/zh-CN/develop/framework/index.md +++ b/docs/user/develop/framework/index.zh.md @@ -1,5 +1,7 @@ # 插件与生命周期 +[English](index.md) | 中文 + 深入了解 Cordis 插件模型和生命周期状态机。 ## Fiber 状态机 @@ -25,11 +27,11 @@ ACTIVE → UNLOADING → DISPOSED 声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪: -```typescript +```ts ignore-check export const inject = ['tools', 'llm'] export function apply(ctx: Context) { - // 到这里时,ctx.tools 和 ctx.llm 一定存在 + // ctx.tools and ctx.llm are ready here. } ``` @@ -39,12 +41,12 @@ export function apply(ctx: Context) { 通过 `ctx` 做的任何注册,在插件卸载时都会自动撤销: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 事件监听——卸载时自动移除 + // Event listener: removed automatically on unload. ctx.on('some-event', handler) - // 自定义资源——卸载时调用返回的函数 + // Custom resource: the returned disposer runs on unload. ctx.effect(() => { const connection = createConnection() return () => connection.close() @@ -58,18 +60,18 @@ export function apply(ctx: Context) { - `ctx.llm.registerAdapter(names, adapter)` — LLM 适配器注册 - `ctx.effect(() => cleanup)` — 自定义资源 -插件卸载时,这些注册按倒序逐个撤销。 +插件卸载时,处置器按注册顺序的反向发起,但多个异步处置器会并发执行,不保证逐个完成。存在顺序依赖的清理步骤必须放进同一个 `ctx.effect()` 返回的处置器中,由该处置器负责串行等待。 ## 嵌套上下文 `ctx.plugin()` 创建子 Fiber,它继承父上下文但有独立的生命周期: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 注册一个子插件 + // Register a child plugin. ctx.plugin(childPlugin) - // 子插件有自己的 Fiber,父卸载时子也卸载 + // The child has its own Fiber and unloads with its parent. } ``` @@ -77,10 +79,10 @@ export function apply(ctx: Context) { 当你需要提前终止一个插件实例: -```typescript +```ts ignore-check const fiber = ctx.plugin(myPlugin) -// 之后可以手动 dispose +// Dispose it manually later. fiber.dispose() ``` @@ -101,7 +103,7 @@ fiber.dispose() ## 实战:理解生命周期 -```typescript +```ts ignore-check export function apply(ctx: Context) { console.log('plugin loading') diff --git a/docs/user/develop/framework/service.i18n.yaml b/docs/user/develop/framework/service.i18n.yaml new file mode 100644 index 0000000000..f0deb18959 --- /dev/null +++ b/docs/user/develop/framework/service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +service.md: 1bf28cb3c7dfdfbd6d0babfa3b1688ac65eea01e +service.zh.md: 17785c056ab9a0a21974e6ed8bbe7f7de05fa00e diff --git a/docs/user/develop/framework/service.md b/docs/user/develop/framework/service.md new file mode 100644 index 0000000000..1bf28cb3c7 --- /dev/null +++ b/docs/user/develop/framework/service.md @@ -0,0 +1,148 @@ +# Services and dependencies + +English | [中文](service.zh.md) + +A service is a capability one plugin exposes to other plugins. `inject` declares the services a plugin requires. + +## What is a service? + +In Harness, `tools`, `llm`, and `agents` are services. Each is a named capability mounted on `ctx`: + +```ts ignore-check +ctx.tools // ToolRegistry service +ctx.llm // LLM service +ctx.agents // Agent service +``` + +Any plugin can provide a service for other plugins to consume. + +## Consume a service + +Declare `inject` to use an existing service: + +```ts ignore-check +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools exists and is ready here. + ctx.tools.register(/* ... */) +} +``` + +When `apply` runs, every service declared by `inject` is ready. If a service is not ready, the plugin waits instead of running. + +## Provide a service + +### Extend Service + +```ts +import { Service, type Context } from 'cordis' + +export default class MetricsService extends Service { + static inject = ['llm'] // A service may depend on other services. + + constructor(ctx: Context) { + super(ctx, 'metrics') // 'metrics' is the service name. + } + + // Public service method. + record(event: string, value: number) { + // ... + } +} +``` + +After loading this plugin, consumers access the service as `ctx.metrics`: + +```ts ignore-check +export const inject = ['metrics'] + +export function apply(ctx: Context) { + ctx.metrics.record('tool_call', 1) +} +``` + +### Declare its type + +Use TypeScript declaration merging to type `ctx.metrics`: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + metrics: MetricsService + } +} + +export default class MetricsService extends Service { + constructor(ctx: Context) { + super(ctx, 'metrics') + } + + record(event: string, value: number) { /* ... */ } +} +``` + +## Dependency behavior + +### Required and optional dependencies + +```ts ignore-check +// Required: the plugin does not load while the service is absent. +export const inject = ['tools'] + +// Optional: omit inject and query with ctx.get() at the use site. +export function apply(ctx: Context) { + const metrics = ctx.get('metrics') + metrics?.record('plugin_loaded', 1) +} +``` + +### When a service disappears + +If a required service disappears while the application is running, for example because its provider unloads: + +1. Dependent plugins dispose automatically. +2. They load again when the service returns. + +This prevents a plugin from calling a service that no longer exists. + +## Service isolation + +`cordis.yml` can isolate services so separate plugin groups see separate instances of the same service: + +```yaml +- id: group-a + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 5000 + - name: './src/plugin-a.ts' + +- id: group-b + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + - name: './src/plugin-b.ts' +``` + +`plugin-a` and `plugin-b` each see the Bash instance in their own group, with no cross-group effect. + +## Built-in Harness services + +The repository generates the service names, public methods, and source locations in the [service catalog](../../../cordis-catalog/services.md). Use that catalog and the service's TypeScript interface while developing a plugin; do not maintain a second static list. + +## Next steps + +- [Event system](./events.md) — communicate between plugins without tight coupling +- [Capability layering](../practice/) — use services as capability interfaces diff --git a/docs/user/zh-CN/develop/framework/service.md b/docs/user/develop/framework/service.zh.md similarity index 80% rename from docs/user/zh-CN/develop/framework/service.md rename to docs/user/develop/framework/service.zh.md index 19edf4a975..17785c056a 100644 --- a/docs/user/zh-CN/develop/framework/service.md +++ b/docs/user/develop/framework/service.zh.md @@ -1,15 +1,17 @@ # 服务与依赖 +[English](service.md) | 中文 + 服务 (Service) 是插件对外暴露能力的方式。依赖 (inject) 是插件声明自己需要哪些服务。 ## 什么是服务 在 Harness 中,`tools`、`llm`、`agents` 都是服务。服务是挂载在 `ctx` 上的命名能力: -```typescript -ctx.tools // ToolRegistry 服务 -ctx.llm // LLM 服务 -ctx.agents // Agent 服务 +```ts ignore-check +ctx.tools // ToolRegistry service +ctx.llm // LLM service +ctx.agents // Agent service ``` 任何插件都可以提供一个新服务,供其他插件使用。 @@ -18,11 +20,11 @@ ctx.agents // Agent 服务 声明 `inject` 来使用已有服务: -```typescript +```ts ignore-check export const inject = ['tools'] export function apply(ctx: Context) { - // ctx.tools 在这里一定存在且就绪 + // ctx.tools exists and is ready here. ctx.tools.register(/* ... */) } ``` @@ -33,17 +35,17 @@ export function apply(ctx: Context) { ### 使用 Service 基类 -```typescript +```ts import { Service, type Context } from 'cordis' export default class MetricsService extends Service { - static inject = ['llm'] // 本服务也可以依赖其他服务 + static inject = ['llm'] // A service may depend on other services. constructor(ctx: Context) { - super(ctx, 'metrics') // 'metrics' 是服务名 + super(ctx, 'metrics') // 'metrics' is the service name. } - // 服务的公开方法 + // Public service method. record(event: string, value: number) { // ... } @@ -52,7 +54,7 @@ export default class MetricsService extends Service { 加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它: -```typescript +```ts ignore-check export const inject = ['metrics'] export function apply(ctx: Context) { @@ -64,7 +66,7 @@ export function apply(ctx: Context) { 使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: -```typescript +```ts import { Service, type Context } from 'cordis' declare module 'cordis' { @@ -86,11 +88,11 @@ export default class MetricsService extends Service { ### 必选依赖 vs 可选依赖 -```typescript -// 必选:服务不存在时,插件不会加载 +```ts ignore-check +// Required: the plugin does not load while the service is absent. export const inject = ['tools'] -// 可选:不写入 inject,使用时通过 ctx.get() 查询 +// Optional: omit inject and query with ctx.get() at the use site. export function apply(ctx: Context) { const metrics = ctx.get('metrics') metrics?.record('plugin_loaded', 1) @@ -138,7 +140,7 @@ export function apply(ctx: Context) { ## Harness 内置服务 -服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 +服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 ## 下一步 diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml new file mode 100644 index 0000000000..d2478abf75 --- /dev/null +++ b/docs/user/develop/practice/index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +index.md: 0261b49b071167f7c2a33f78bbc1959cc6f1879f +index.zh.md: 5819344430fcbde31bf825e9815120983e44e3f6 diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md new file mode 100644 index 0000000000..0261b49b07 --- /dev/null +++ b/docs/user/develop/practice/index.md @@ -0,0 +1,158 @@ +# Three-layer capability design + +English | [中文](index.zh.md) + +When a capability is general enough to need replaceable implementations, such as Bash execution, Harness splits it into three packages: an **interface**, an **implementation**, and a **consumer**. Each layer can evolve or be replaced independently. + +## Bash example + +The Bash execution capability consists of: + +- **Interface** (`dsh-bash`) — defines Bash request and result shapes +- **Implementation** (`dsh-bash-local`) — executes commands on the local machine +- **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│ +│ (interface) │ │ (implementation) │ │(consumer/tool)│ +└─────────────┘ └──────────────────┘ └──────────────┘ + ▲ │ + └────────────────────────────────────────────┘ + inject: ['bash'] +``` + +## Benefits of the split + +### Replace implementations + +One interface can have multiple implementations selected through `cordis.yml`: + +```yaml +# Local execution +- name: '@deepseek-ai/dsh-bash-local' + +# Or a future remote sandbox implementation +# - name: '@deepseek-ai/dsh-bash-remote' +# config: +# endpoint: 'https://sandbox.example.com' +``` + +The interface and tool remain unchanged while the implementation changes. + +### Evolve independently + +- The interface changes rarely after its contract stabilizes. +- Implementations can improve performance and security independently. +- Consumers can change how they present the capability to the model. + +### Decouple dependencies + +- The implementation depends on the interface. +- The consumer depends on the interface. +- The implementation and consumer **do not depend on each other**. + +## Built-in three-layer capabilities + +| Capability | Interface | Implementation | Consumer | +|------|-------------|------|---------------| +| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` | +| Filesystem | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | +| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | +| Subagent | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | +| Compaction | `dsh-compact` | `dsh-compact-basic` | The implementation consumes agent-loop extension events | + +## Develop a three-layer capability + +### Step 1: define the interface + +```ts ignore-check +// packages/my-cap/my-cap/src/index.ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + myCap: MyCapService + } +} + +export abstract class MyCapService extends Service { + constructor(ctx: Context) { + super(ctx, 'myCap') + } + + /** Execute the capability. */ + abstract execute(request: MyCapRequest): Promise +} + +export interface MyCapRequest { + input: string +} + +export interface MyCapResult { + output: string +} +``` + +### Step 2: write an implementation + +```ts ignore-check +// packages/my-cap/my-cap-local/src/index.ts +import type { Context } from 'cordis' +import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' + +class MyCapLocal extends MyCapService { + async execute(request: MyCapRequest): Promise { + // Concrete implementation. + return { output: request.input.toUpperCase() } + } +} + +export const name = 'my-cap-local' + +export function apply(ctx: Context) { + ctx.plugin(MyCapLocal) +} +``` + +### Step 3: write a consumer + +```ts ignore-check +// packages/my-cap/tool-my-cap/src/index.ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'tool-my-cap' +export const inject = ['tools', 'myCap'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'my_cap', + description: 'Execute my capability.', + parameters: { + input: { type: 'string', required: true }, + }, + async execute(args) { + const result = await ctx.myCap.execute({ input: args.input }) + return [{ type: 'text', text: result.output }] + }, + })) +} +``` + +### Compose them in cordis.yml + +```yaml +- name: '@deepseek-ai/dsh-my-cap-local' +- name: '@deepseek-ai/dsh-tool-my-cap' +``` + +## Design points + +- **Do not split preemptively** — use three packages only when the capability needs replaceable implementations. A simple tool plugin does not. +- **The interface owns Request/Result types** — implementations and consumers depend only on the interface package. +- **Explicit > implicit** — resolve defaults in an explicit `resolve(request): Spec` step rather than hiding `?? default` expressions inside `run()`. + +## Next steps + +- [LLM adapter](./llm-adapter.md) — implement an LLM backend, a common capability interface extension diff --git a/docs/user/zh-CN/develop/practice/index.md b/docs/user/develop/practice/index.zh.md similarity index 94% rename from docs/user/zh-CN/develop/practice/index.md rename to docs/user/develop/practice/index.zh.md index bffa35f964..5819344430 100644 --- a/docs/user/zh-CN/develop/practice/index.md +++ b/docs/user/develop/practice/index.zh.md @@ -1,5 +1,7 @@ # 能力的三层拆分 +[English](index.md) | 中文 + 当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。 ## 以 Bash 为例 @@ -13,7 +15,7 @@ ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ │ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│ -│ (接口) │ │ (实现) │ │ (消费者/tool)│ +│ (interface) │ │ (implementation) │ │(consumer/tool)│ └─────────────┘ └──────────────────┘ └──────────────┘ ▲ │ └────────────────────────────────────────────┘ @@ -27,10 +29,10 @@ 同一个接口可以有多种实现。用户通过 `cordis.yml` 选择: ```yaml -# 本地执行 +# Local execution - name: '@deepseek-ai/dsh-bash-local' -# 或:远程沙箱执行(未来) +# Or a future remote sandbox implementation # - name: '@deepseek-ai/dsh-bash-remote' # config: # endpoint: 'https://sandbox.example.com' @@ -64,7 +66,7 @@ ### 第一步:定义接口 -```typescript +```ts ignore-check // packages/my-cap/my-cap/src/index.ts import { Service, type Context } from 'cordis' @@ -79,7 +81,7 @@ export abstract class MyCapService extends Service { super(ctx, 'myCap') } - /** 执行能力的核心方法 */ + /** Execute the capability. */ abstract execute(request: MyCapRequest): Promise } @@ -94,14 +96,14 @@ export interface MyCapResult { ### 第二步:编写实现 -```typescript +```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts import type { Context } from 'cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { async execute(request: MyCapRequest): Promise { - // 具体实现 + // Concrete implementation. return { output: request.input.toUpperCase() } } } @@ -115,7 +117,7 @@ export function apply(ctx: Context) { ### 第三步:编写消费者 (tool) -```typescript +```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml new file mode 100644 index 0000000000..84d622dde9 --- /dev/null +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +llm-adapter.md: 18e05ab79f7daf9f86fe1eb27bdd4440fb9107bc +llm-adapter.zh.md: f3c1ac70f7b4c11fb9f6dcb247be342fb358bd39 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md new file mode 100644 index 0000000000..18e05ab79f --- /dev/null +++ b/docs/user/develop/practice/llm-adapter.md @@ -0,0 +1,185 @@ +# LLM adapters + +English | [中文](llm-adapter.zh.md) + +This guide connects a new LLM provider to Harness. + +## Overview + +An LLM adapter extends `LlmAdapter` and implements `stream()`, translating Harness's provider-neutral request into a provider API call and translating the response back into Harness chunks. + +## Minimal implementation + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class MyAdapter extends LlmAdapter { + private apiKey: string + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async *stream(options: GenerateOptions): AsyncIterable { + // 1. Convert options.messages to the provider format. + // 2. Call the streaming API. + // 3. Convert the response into StreamChunk values. + } +} + +export interface Config { + apiKey: string + models: string[] +} + +export const Config: Schema = Schema.object({ + apiKey: Schema.string().required(), + models: Schema.array(Schema.string()).required(), +}) + +export const name = 'my-llm-adapter' +export const inject = ['llm'] + +export function apply(ctx: Context, config: Config) { + const adapter = new MyAdapter(config.apiKey) + ctx.llm.registerAdapter(config.models, adapter) +} +``` + +## StreamChunk protocol + +`stream()` yields chunks using this protocol: + +```ts +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' + +async function* exampleChunks(): AsyncIterable { + // 1. Start each content block with block-start. + yield { type: 'block-start', index: 0, blockType: 'text' } + + // 2. Stream text through text-delta. + yield { type: 'text-delta', index: 0, text: 'Hello' } + yield { type: 'text-delta', index: 0, text: ' world' } + + // 3. End each content block with block-end and the complete block. + yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, + } + + // 4. Tool-call block. + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, + id: CallId('call-123'), + name: 'bash', + argumentsDelta: '{"command":"ls"}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, + } + + // 5. Token usage. + yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + + // 6. Finish reason. + yield { type: 'finish', reason: { kind: 'stop' } } + // Alternatively, { kind: 'tool-calls' } requests tool execution. +} +``` + +### Key rules + +- Every `block-start` has a matching `block-end`. +- `index` increases from 0 and identifies content-block order. +- A `tool-call-delta` carries raw JSON text in `argumentsDelta`, either all at once or over multiple chunks. +- `finish` is the final chunk. +- Emit `usage` before `finish`. + +## GenerateOptions + +`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +## Register an adapter + +```ts ignore-check +ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) +``` + +The first argument lists the model names handled by the adapter. If `cordis.yml` selects `model: model-name-1`, the service routes that request to this adapter. + +## Use it from cordis.yml + +```yaml +- id: my-llm + name: './src/my-llm-adapter.ts' + config: + apiKey: !!js process.env.MY_API_KEY + models: + - my-model-v1 + - my-model-v2 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: my-model-v1 # References the model registered above. +``` + +## Reference implementations + +The repository contains complete implementations: + +- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format +- `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format +- `examples/echo-agent/src/mock-llm.ts` — minimal local teaching adapter + +Start with the mock adapter to study a complete chunk sequence without network behavior. + +## Error handling + +Adapters throw transport and protocol failures as `LlmError` values with stable codes. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`. + +```ts +import { + attributionHeaders, + LlmAdapter, + LlmError, + type GenerateOptions, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +class HttpAdapter extends LlmAdapter { + constructor(private readonly endpoint: string) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...attributionHeaders(), + }, + body: JSON.stringify({ model: options.model, messages: options.messages }), + ...options.signal ? { signal: options.signal } : {}, + }) + if (!response.ok) { + throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status) + } + // A real adapter parses the response and emits the complete chunk sequence. + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +``` diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md new file mode 100644 index 0000000000..f3c1ac70f7 --- /dev/null +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -0,0 +1,185 @@ +# LLM 适配器 + +[English](llm-adapter.md) | 中文 + +本文介绍如何为 Harness 接入一个新的 LLM 提供方。 + +## 概述 + +LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 + +## 最小实现 + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class MyAdapter extends LlmAdapter { + private apiKey: string + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async *stream(options: GenerateOptions): AsyncIterable { + // 1. Convert options.messages to the provider format. + // 2. Call the streaming API. + // 3. Convert the response into StreamChunk values. + } +} + +export interface Config { + apiKey: string + models: string[] +} + +export const Config: Schema = Schema.object({ + apiKey: Schema.string().required(), + models: Schema.array(Schema.string()).required(), +}) + +export const name = 'my-llm-adapter' +export const inject = ['llm'] + +export function apply(ctx: Context, config: Config) { + const adapter = new MyAdapter(config.apiKey) + ctx.llm.registerAdapter(config.models, adapter) +} +``` + +## StreamChunk 协议 + +`stream()` 必须按以下协议 yield chunk: + +```ts +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' + +async function* exampleChunks(): AsyncIterable { + // 1. Start each content block with block-start. + yield { type: 'block-start', index: 0, blockType: 'text' } + + // 2. Stream text through text-delta. + yield { type: 'text-delta', index: 0, text: 'Hello' } + yield { type: 'text-delta', index: 0, text: ' world' } + + // 3. End each content block with block-end and the complete block. + yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, + } + + // 4. Tool-call block. + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, + id: CallId('call-123'), + name: 'bash', + argumentsDelta: '{"command":"ls"}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, + } + + // 5. Token usage. + yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + + // 6. Finish reason. + yield { type: 'finish', reason: { kind: 'stop' } } + // Alternatively, { kind: 'tool-calls' } requests tool execution. +} +``` + +### 关键规则 + +- 每个 `block-start` 必须有对应的 `block-end` +- `index` 从 0 递增,标识内容块顺序 +- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) +- `finish` 必须是最后一个 chunk +- `usage` 在 `finish` 之前 yield + +## GenerateOptions + +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +## 注册适配器 + +```ts ignore-check +ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) +``` + +第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 + +## 在 cordis.yml 中使用 + +```yaml +- id: my-llm + name: './src/my-llm-adapter.ts' + config: + apiKey: !!js process.env.MY_API_KEY + models: + - my-model-v1 + - my-model-v2 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: my-model-v1 # References the model registered above. +``` + +## 实战参考 + +仓库中有两个完整实现可供参考: + +- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) +- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) + +mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 + +## 错误处理 + +适配器应将传输和协议故障作为带稳定 code 的 `LlmError` 抛出;agent loop 会保留该错误及其 code,供诊断和策略使用。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 + +```ts +import { + attributionHeaders, + LlmAdapter, + LlmError, + type GenerateOptions, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +class HttpAdapter extends LlmAdapter { + constructor(private readonly endpoint: string) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...attributionHeaders(), + }, + body: JSON.stringify({ model: options.model, messages: options.messages }), + ...options.signal ? { signal: options.signal } : {}, + }) + if (!response.ok) { + throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status) + } + // A real adapter parses the response and emits the complete chunk sequence. + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +``` diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml new file mode 100644 index 0000000000..c917b46a3e --- /dev/null +++ b/docs/user/guide/config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +config.md: 0cad49d9bd82813da230a44d526190d4e0b6a730 +config.zh.md: ee1ea9a0c1d9010be8c1a3984acdcbe870e30584 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md new file mode 100644 index 0000000000..0cad49d9bd --- /dev/null +++ b/docs/user/guide/config.md @@ -0,0 +1,59 @@ +# Configuration + +English | [中文](config.zh.md) + +Harness uses `cordis.yml` to describe which plugins an agent loads and the configuration passed to each one. The file composes capabilities; the generated configuration catalog records the fields and defaults each package actually supports, avoiding a second hand-maintained reference. + +## Start from a real configuration + +The repository examples are runnable configurations and the most reliable starting points for a new project: + +- [echo-agent](../../../examples/echo-agent/cordis.yml) uses a local mock model and needs no API key. +- [coding-agent](../../../examples/coding-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, and workflows. +- [acp-agent](../../../examples/acp-agent/cordis.yml) connects to editor clients over ACP. + +A minimal configuration is a list of plugin entries: + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## Plugin entries + +`name` identifies an npm package or a local module relative to `cordis.yml`; `id` gives the plugin instance a stable identity; and `config` supplies plugin-specific configuration. Set `disabled: true` to skip an entry temporarily. + +```yaml +- id: local-tool + name: './src/my-tool.ts' + disabled: false + config: + toolName: my_tool +``` + +Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. + +## JavaScript values and environment variables + +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. + +```yaml +config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + cwd: !!js process.cwd() +``` + +The tag is `!!js`, not `!js`. + +## Exact configuration reference + +The generated [plugin configuration catalog](../../config-catalog.md) lists every current field, type, and default. For composition concepts, continue to the [architecture](../../architecture.md) and [capability interfaces](../../capability-seams.md). To create a configuration, copy the closest entry from the [examples overview](../../../examples/README.md) and adapt it. diff --git a/docs/user/zh-CN/guide/config.md b/docs/user/guide/config.zh.md similarity index 71% rename from docs/user/zh-CN/guide/config.md rename to docs/user/guide/config.zh.md index 5e13b69d9f..ee1ea9a0c1 100644 --- a/docs/user/zh-CN/guide/config.md +++ b/docs/user/guide/config.zh.md @@ -1,14 +1,16 @@ # 配置文件 +[English](config.md) | 中文 + Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 ## 从真实配置开始 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [echo-agent](../../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 -- [coding-agent](../../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 -- [acp-agent](../../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 +- [echo-agent](../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 +- [coding-agent](../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [acp-agent](../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 最小配置由一组插件条目组成: @@ -54,4 +56,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../../architecture.md)和[能力接口](../../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml new file mode 100644 index 0000000000..12ccac1bcb --- /dev/null +++ b/docs/user/guide/index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +index.md: 4daf7fdc76b38b5d10cc8b4726d9b6239a72933f +index.zh.md: 1f41078822009234cbaf513aaa8dbc01c4b5d879 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md new file mode 100644 index 0000000000..4daf7fdc76 --- /dev/null +++ b/docs/user/guide/index.md @@ -0,0 +1,49 @@ +# Introduction + +English | [中文](index.zh.md) + +DeepSeek Harness is a **plugin-based agent development framework** built on the [Cordis](https://github.com/cordiverse/cordis) microkernel. Its central idea is simple: **everything is a plugin**. + +## What it is + +Harness implements every capability an AI agent needs—including LLM calls, tool execution, session management, and subtask delegation—as a composable plugin. A `cordis.yml` file declares which plugins to load and how to configure them, assembling a complete agent. + +```yaml +# Select the LLM backend +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + +# Select the application template +- name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## Who it is for + +### Application users + +To run an existing agent application, such as a coding assistant or conversational agent: + +1. Copy an example template. +2. Add an API key. +3. Run it. + +No code is required. See the [quick start](./quickstart.md). + +### Plugin developers + +To add a custom tool, a new LLM adapter, or another execution backend, write a plugin. Harness provides explicit extension interfaces and a type-safe development experience. See [development](../develop/basic/). + +## Core features + +- **Configuration only** — `cordis.yml` selects the capability set; changing a model or adding a tool is a configuration edit. +- **Hot replacement (HMR)** — edit plugin code during development without restarting the process. + +## Technology + +- **Runtime**: Node.js ^22.19 or >= 24 +- **Language**: TypeScript (ESM) +- **Framework**: Cordis +- **Package manager**: pnpm workspaces (the repository pins pnpm 11) diff --git a/docs/user/zh-CN/guide/index.md b/docs/user/guide/index.zh.md similarity index 94% rename from docs/user/zh-CN/guide/index.md rename to docs/user/guide/index.zh.md index 8c7f7e603a..1f41078822 100644 --- a/docs/user/zh-CN/guide/index.md +++ b/docs/user/guide/index.zh.md @@ -1,5 +1,7 @@ # 介绍 +[English](index.md) | 中文 + DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**。 ## 它是什么 @@ -7,12 +9,12 @@ DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis]( Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 Agent。 ```yaml -# 选择 LLM 后端 +# Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# 选择应用模板 +# Select the application template - name: '@deepseek-ai/dsh-stdio-agent' config: model: deepseek-v4-flash diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml new file mode 100644 index 0000000000..310dfaa62f --- /dev/null +++ b/docs/user/guide/quickstart.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +quickstart.md: c2932cf5a5f49c3e3ddb887866b421c6e6cad7a1 +quickstart.zh.md: 54ddb50be31203e98d4cd75d0b8851f8f0e14cc5 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md new file mode 100644 index 0000000000..c2932cf5a5 --- /dev/null +++ b/docs/user/guide/quickstart.md @@ -0,0 +1,99 @@ +# Quick start + +English | [中文](quickstart.zh.md) + +This guide gets an agent running in five minutes. + +## Prerequisites + +- [Node.js](https://nodejs.org/) ^22.19 or >= 24 +- [pnpm](https://pnpm.io/) 11 (use Corepack to select the repository-pinned version) + +```sh +# Check versions +node -v # v22.19.x, or v24.x and newer +corepack enable +pnpm -v # 11.x +``` + +## Step 1: run echo-agent + +echo-agent needs no API key and runs after dependencies are installed. + +```sh +# Clone the repository +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness + +# Install dependencies +pnpm install + +# Start echo-agent +pnpm run demo:echo +``` + +The process prints: + +``` +echo-agent ready. Type a message ("echo " triggers the tool). +> +``` + +Enter: + +``` +> echo hello world +``` + +The model issues a tool call, and the echo tool returns the text in uppercase: + +``` +[tool call] echo({"text":"hello world"}) +[tool result] ECHO: HELLO WORLD +``` + +Your local environment is ready. + +## Step 2: use a real model + +Next, connect a real DeepSeek model and run the complete command-line agent. + +### Get an API key + +Get an API key from [DeepSeek Platform](https://platform.deepseek.com/). + +### Configure the environment + +Create a gitignored `.env` file in the repository root: + +```sh +DEEPSEEK_API_KEY=sk-your-key-here +``` + +### Start coding-agent + +```sh +pnpm run demo:repl +``` + +``` +agent REPL ready. Give it a coding task. +> +``` + +This is a complete coding assistant that can read and write files, run commands, and delegate subtasks. + +Try a task: + +``` +> Create hello.js in the current directory, print "Hello from Harness!", and run it +``` + +## What happened + +echo-agent and coding-agent use the same application framework (`@deepseek-ai/dsh-stdio-agent`). Their `cordis.yml` files select different plugins and configuration. Custom agents use the same composition model. + +## Next steps + +- [Configuration](./config.md) — understand the `cordis.yml` format +- [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/zh-CN/guide/quickstart.md b/docs/user/guide/quickstart.zh.md similarity index 88% rename from docs/user/zh-CN/guide/quickstart.md rename to docs/user/guide/quickstart.zh.md index 7ca4b19332..54ddb50be3 100644 --- a/docs/user/zh-CN/guide/quickstart.md +++ b/docs/user/guide/quickstart.zh.md @@ -1,5 +1,7 @@ # 快速开始 +[English](quickstart.md) | 中文 + 本指南带你在 5 分钟内跑起一个 Agent。 ## 环境准备 @@ -8,8 +10,8 @@ - [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) ```sh -# 确认版本 -node -v # v22.19.x,或 v24.x 及更高版本 +# Check versions +node -v # v22.19.x, or v24.x and newer corepack enable pnpm -v # 11.x ``` @@ -19,14 +21,14 @@ pnpm -v # 11.x echo-agent 不需要 API key,装好依赖就能跑。 ```sh -# 克隆仓库 +# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness -# 安装依赖 +# Install dependencies pnpm install -# 启动 echo-agent +# Start echo-agent pnpm run demo:echo ``` @@ -84,7 +86,7 @@ agent REPL ready. Give it a coding task. 试着给它一个任务: ``` -> 在当前目录创建一个 hello.js,内容是打印 "Hello from Harness!",然后运行它 +> Create hello.js in the current directory, print "Hello from Harness!", and run it ``` ## 回头看 diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml new file mode 100644 index 0000000000..b3fc8da2d2 --- /dev/null +++ b/docs/user/index.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +index.md: e9a1f03785c7472c47550ec59ea0165d28d3d9a6 +index.zh.md: 907f1452c9ff50d619989c18dcf2727addb2573d diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..e9a1f03785 --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +layout: home +hero: + name: DeepSeek Harness + text: Plugin-based agent development framework + tagline: Built on the Cordis microkernel; everything is a plugin + actions: + - theme: brand + text: Quick start + link: /en/guide/quickstart + - theme: alt + text: Develop plugins + link: /en/develop/basic/ +features: + - title: Plugin architecture + details: Built on the Cordis plugin system. Every capability is registered by a plugin, takes effect when loaded, and is reverted when unloaded. + - title: Configuration as composition + details: One cordis.yml determines the agent's complete capability set. Change a model or add a tool by editing configuration. + - title: Ready to use + details: Includes LLM calls, file access, Bash execution, subagent delegation, and the rest of the core toolchain. Copy a template to get started. +--- + +# DeepSeek Harness + +English | [中文](index.zh.md) diff --git a/docs/user/zh-CN/index.md b/docs/user/index.zh.md similarity index 93% rename from docs/user/zh-CN/index.md rename to docs/user/index.zh.md index 1c495125c6..907f1452c9 100644 --- a/docs/user/zh-CN/index.md +++ b/docs/user/index.zh.md @@ -19,3 +19,7 @@ features: - title: 开箱即用 details: 内置 LLM 调用、文件读写、Bash 执行、子代理委派等完整工具链,复制模板即可运行。 --- + +# DeepSeek Harness + +[English](index.md) | 中文 diff --git a/docs/user/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md deleted file mode 100644 index 0b0ae3cff0..0000000000 --- a/docs/user/zh-CN/develop/practice/llm-adapter.md +++ /dev/null @@ -1,174 +0,0 @@ -# LLM 适配器 - -本文介绍如何为 Harness 接入一个新的 LLM 提供方。 - -## 概述 - -LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 - -## 最小实现 - -```typescript -import type { Context } from 'cordis' -import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' - -class MyAdapter extends LlmAdapter { - private apiKey: string - - constructor(apiKey: string) { - super() - this.apiKey = apiKey - } - - async *stream(options: GenerateOptions): AsyncIterable { - // 1. 将 options.messages 转换为你的 API 格式 - // 2. 调用 API(流式) - // 3. 将 API 响应转换为 StreamChunk 序列 - } -} - -export interface Config { - apiKey: string - models: string[] -} - -export const name = 'my-llm-adapter' -export const inject = ['llm'] - -export function apply(ctx: Context, config: Config) { - const adapter = new MyAdapter(config.apiKey) - ctx.llm.registerAdapter(config.models, adapter) -} -``` - -## StreamChunk 协议 - -`stream()` 必须按以下协议 yield chunk: - -```typescript -// 1. 每个内容块以 block-start 开始 -yield { type: 'block-start', index: 0, blockType: 'text' } - -// 2. 文本块使用 text-delta -yield { type: 'text-delta', index: 0, text: 'Hello' } -yield { type: 'text-delta', index: 0, text: ' world' } - -// 3. 每个内容块以 block-end 结束(携带完整 block) -yield { - type: 'block-end', - index: 0, - block: { type: 'text', text: 'Hello world' }, -} - -// 4. Tool call 块 -yield { type: 'block-start', index: 1, blockType: 'tool-call' } -yield { - type: 'tool-call-delta', - index: 1, - id: CallId('call-123'), - name: 'bash', - argumentsDelta: '{"command":"ls"}', -} -yield { - type: 'block-end', - index: 1, - block: { - type: 'tool-call', - id: CallId('call-123'), - name: 'bash', - arguments: '{"command":"ls"}', - }, -} - -// 5. Token 用量 -yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } - -// 6. 结束原因 -yield { type: 'finish', reason: { kind: 'stop' } } -// 或: { kind: 'tool-calls' } 表示模型想调用 tool -``` - -### 关键规则 - -- 每个 `block-start` 必须有对应的 `block-end` -- `index` 从 0 递增,标识内容块顺序 -- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) -- `finish` 必须是最后一个 chunk -- `usage` 在 `finish` 之前 yield - -## GenerateOptions - -`stream()` 接收的请求包含: - -```typescript -interface GenerateOptions { - /** 模型名 */ - model: string - /** 对话历史 */ - messages: Message[] - /** 可用的 tool 列表 */ - tools?: ToolSpec[] - /** 系统提示词 */ - system?: string - /** 最大输出 token */ - maxTokens?: number - /** 温度 */ - temperature?: number - /** 取消或卸载时中止进行中的请求 */ - signal?: AbortSignal -} -``` - -你的适配器需要将这些映射到具体 API 的参数。 - -## 注册适配器 - -```typescript -ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) -``` - -第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 - -## 在 cordis.yml 中使用 - -```yaml -- id: my-llm - name: './src/my-llm-adapter.ts' - config: - apiKey: !!js process.env.MY_API_KEY - models: - - my-model-v1 - - my-model-v2 - -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: my-model-v1 # 引用上面注册的模型名 -``` - -## 实战参考 - -仓库中有两个完整实现可供参考: - -- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) -- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) -- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) - -mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 - -## 错误处理 - -适配器中的异常会被 agent-loop 捕获并转化为 `LlmError`,告知上层。不需要在 `stream()` 内部做错误恢复——让异常冒泡即可。 - -```typescript -async *stream(options: GenerateOptions): AsyncIterable { - const response = await fetch(this.endpoint, { - // ...method、headers 和 body - signal: options.signal, - }) - if (!response.ok) { - throw new Error(`API error: ${response.status}`) - } - // ... 正常流式处理 -} -``` diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 9a6162576d..19417d5d99 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -20,6 +20,7 @@ function fixture(): { root: string; pages: DocsPage[] } { mkdirSync(join(root, 'packages'), { recursive: true }) writeFileSync(join(root, 'docs/a.md'), '# A\n') writeFileSync(join(root, 'docs/b.md'), '# B\n') + writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n') writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n') writeFileSync(join(root, 'packages/logo.svg'), '\n') return { @@ -88,6 +89,46 @@ describe('rewriteMarkdown', () => { })).toBe(source) }) + it('replaces the destination token without changing repeated titles or escapes', () => { + const { root, pages } = fixture() + const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n' + expect(rewriteMarkdown(source, { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe( + '[title](./reference/b.md "b.md") ' + + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n', + ) + }) + + it('routes a pair switcher across locales while ordinary links stay in locale', () => { + const { root, pages } = fixture() + writeFileSync(join(root, 'docs/a.zh.md'), '# A\n') + const paired = pages.filter(page => page.source !== 'docs/a.md') + paired.push( + { + locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'], + route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1, + }, + { + locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'], + route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1, + }, + ) + expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', { + locale: 'root', + sourcePath: 'docs/a.zh.md', + route: 'guide/a.md', + pages: paired, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n') + }) + it('fails loud when a relative target is missing', () => { const { root, pages } = fixture() expect(() => rewriteMarkdown('[missing](missing.md)\n', { @@ -102,14 +143,21 @@ describe('rewriteMarkdown', () => { }) describe('docsPages locale routes', () => { - it('publishes the same canonical source at every corresponding locale route', () => { + it('publishes every route in both locales and selects paired user sources', () => { const byRoute = new Map(docsPages.map(page => [page.route, page])) for (const page of docsPages.filter(page => page.locale === 'root')) { const counterpart = byRoute.get(`en/${page.route}`) expect(counterpart, page.route).toBeDefined() expect(counterpart?.locale).toBe('en') - expect(counterpart?.source).toBe(page.source) - expect(counterpart?.contentLocale).toBe(page.contentLocale) + if (page.source.startsWith('docs/user/')) { + expect(page.source).toMatch(/\.zh\.md$/) + expect(page.contentLocale).toBe('zh-CN') + expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md')) + expect(counterpart?.contentLocale).toBe('en-US') + } else { + expect(counterpart?.source).toBe(page.source) + expect(counterpart?.contentLocale).toBe(page.contentLocale) + } } }) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 7ef35a6d28..5c43e6f46a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -23,6 +23,13 @@ interface Replacement { value: string } +interface DestinationRange { + start: number + end: number +} + +type RewritableNode = Extract + /** Inputs for rewriting one canonical Markdown page. */ export interface RewriteMarkdownOptions { locale: DocsLocale @@ -44,6 +51,75 @@ function isExternalOrSiteAbsolute(url: string): boolean { || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) } +function skipWhitespace(source: string, start: number): number { + let index = start + while (/\s/.test(source[index] ?? '')) index += 1 + return index +} + +function labelEnd(source: string): number { + const first = source.indexOf('[') + if (first === -1) return -1 + let depth = 0 + for (let index = first; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') { + index += 1 + } else if (char === '[') { + depth += 1 + } else if (char === ']') { + depth -= 1 + if (depth === 0) return index + } + } + return -1 +} + +function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange { + const endOfLabel = labelEnd(rawNode) + if (endOfLabel === -1) { + throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`) + } + + let start: number + if (type === 'definition') { + const colon = rawNode.indexOf(':', endOfLabel + 1) + if (colon === -1) { + throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`) + } + start = skipWhitespace(rawNode, colon + 1) + } else { + if (rawNode[endOfLabel + 1] !== '(') { + throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`) + } + start = skipWhitespace(rawNode, endOfLabel + 2) + } + + if (rawNode[start] === '<') { + for (let index = start + 1; index < rawNode.length; index += 1) { + if (rawNode[index] === '\\') index += 1 + else if (rawNode[index] === '>') return { start: start + 1, end: index } + } + throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`) + } + + let depth = 0 + for (let index = start; index < rawNode.length; index += 1) { + const char = rawNode[index] + if (char === '\\') { + index += 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + if (depth === 0) return { start, end: index } + depth -= 1 + } else if (/\s/.test(char ?? '') && depth === 0) { + return { start, end: index } + } + } + return { start, end: rawNode.length } +} + function splitTarget(url: string): { path: string; suffix: string } { const boundary = url.search(/[?#]/) if (boundary === -1) return { path: url, suffix: '' } @@ -78,6 +154,12 @@ function sourceMap(pages: DocsPage[]): Map> { return map } +function counterpartSource(source: string): string { + return source.endsWith('.zh.md') + ? source.replace(/\.zh\.md$/, '.md') + : source.replace(/\.md$/, '.zh.md') +} + function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } { const decoded = decodePath(rawPath) let absPath = resolve(dirname(sourceAbs), decoded) @@ -129,13 +211,17 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) const replacements: Replacement[] = [] - const rewrite = (node: Nodes & { url: string }): void => { + const rewrite = (node: RewritableNode): void => { if (isExternalOrSiteAbsolute(node.url)) return const { path, suffix } = splitTarget(node.url) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) - const page = published.get(targetPath)?.get(options.locale) + const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath) + const targetLocale: DocsLocale = isLanguageSwitcher + ? options.locale === 'root' ? 'en' : 'root' + : options.locale + const page = published.get(targetPath)?.get(targetLocale) const nextUrl = page === undefined ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') : routeTarget(options.route, page.route, suffix) @@ -146,13 +232,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) } const rawNode = source.slice(start, end) - const urlOffset = rawNode.lastIndexOf(node.url) - if (urlOffset === -1) { - throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`) - } + const rawDestination = destinationRange(rawNode, node.type) replacements.push({ - start: start + urlOffset, - end: start + urlOffset + node.url.length, + start: start + rawDestination.start, + end: start + rawDestination.end, value: nextUrl, }) } diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 35a957e57d..c79d5c1492 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -11,6 +11,18 @@ "docs/development.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", + "docs/user/develop/basic/config.md", + "docs/user/develop/basic/index.md", + "docs/user/develop/basic/tool.md", + "docs/user/develop/framework/events.md", + "docs/user/develop/framework/index.md", + "docs/user/develop/framework/service.md", + "docs/user/develop/practice/index.md", + "docs/user/develop/practice/llm-adapter.md", + "docs/user/guide/config.md", + "docs/user/guide/index.md", + "docs/user/guide/quickstart.md", + "docs/user/index.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", "python/README.md", diff --git a/website/docs.ts b/website/docs.ts index 16d53e5b8a..0e6897afe3 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -42,63 +42,93 @@ export interface DocsPage { } interface MirroredPage { - source: string + source: string | Record route: string - contentLocale: DocsPage['contentLocale'] + contentLocale: DocsPage['contentLocale'] | Record label: Record sidebar: Record section: Record order: number + sourceAliases?: string[] | Partial> +} + +type PairedPage = Omit & { + /** English side of a sibling `foo.md` / `foo.zh.md` pair. */ + source: string + /** Language-neutral repository aliases, such as the directory of an index page. */ sourceAliases?: string[] } -function mirroredPages(pages: MirroredPage[]): DocsPage[] { - return pages.flatMap(page => (['root', 'en'] as const).map(locale => ({ - locale, - contentLocale: page.contentLocale, - source: page.source, - route: locale === 'root' ? page.route : `en/${page.route}`, - label: page.label[locale], - sidebar: page.sidebar[locale], - section: page.section[locale], - order: page.order, - ...(page.sourceAliases === undefined ? {} : { sourceAliases: page.sourceAliases }), - }))) +function localized(value: T | Record, locale: DocsLocale): T { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record)[locale] + : value } -const homeAndGuide = mirroredPages([ +function mirroredPages(pages: MirroredPage[]): DocsPage[] { + return pages.flatMap(page => (['root', 'en'] as const).map((locale) => { + const aliases = page.sourceAliases === undefined + ? undefined + : Array.isArray(page.sourceAliases) ? page.sourceAliases : page.sourceAliases[locale] + return { + locale, + contentLocale: localized(page.contentLocale, locale), + source: localized(page.source, locale), + route: locale === 'root' ? page.route : `en/${page.route}`, + label: page.label[locale], + sidebar: page.sidebar[locale], + section: page.section[locale], + order: page.order, + ...(aliases === undefined ? {} : { sourceAliases: aliases }), + } + })) +} + +function pairedPages(pages: PairedPage[]): DocsPage[] { + return mirroredPages(pages.map((page) => { + const chineseSource = page.source.replace(/\.md$/, '.zh.md') + const sharedAliases = page.sourceAliases ?? [] + return { + ...page, + source: { root: chineseSource, en: page.source }, + contentLocale: { root: 'zh-CN', en: 'en-US' }, + sourceAliases: { + root: [...sharedAliases, page.source], + en: [...sharedAliases, chineseSource], + }, + } + })) +} + +const homeAndGuide = pairedPages([ { - source: 'docs/user/zh-CN/index.md', + source: 'docs/user/index.md', route: 'index.md', - contentLocale: 'zh-CN', label: { root: 'DeepSeek Harness', en: 'DeepSeek Harness' }, sidebar: { root: null, en: null }, section: { root: '首页', en: 'Home' }, order: 0, }, { - source: 'docs/user/zh-CN/guide/index.md', + source: 'docs/user/guide/index.md', route: 'guide/index.md', - contentLocale: 'zh-CN', label: { root: '介绍', en: 'Introduction' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 1, - sourceAliases: ['docs/user/zh-CN/guide'], + sourceAliases: ['docs/user/guide'], }, { - source: 'docs/user/zh-CN/guide/quickstart.md', + source: 'docs/user/guide/quickstart.md', route: 'guide/quickstart.md', - contentLocale: 'zh-CN', label: { root: '快速开始', en: 'Quick start' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 2, }, { - source: 'docs/user/zh-CN/guide/config.md', + source: 'docs/user/guide/config.md', route: 'guide/config.md', - contentLocale: 'zh-CN', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, @@ -106,77 +136,69 @@ const homeAndGuide = mirroredPages([ }, ]) -const develop = mirroredPages([ +const develop = pairedPages([ { - source: 'docs/user/zh-CN/develop/basic/index.md', + source: 'docs/user/develop/basic/index.md', route: 'develop/basic/index.md', - contentLocale: 'zh-CN', label: { root: '第一个插件', en: 'First plugin' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/basic'], + sourceAliases: ['docs/user/develop/basic'], }, { - source: 'docs/user/zh-CN/develop/basic/tool.md', + source: 'docs/user/develop/basic/tool.md', route: 'develop/basic/tool.md', - contentLocale: 'zh-CN', label: { root: '开发一个 Tool', en: 'Build a tool' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 2, }, { - source: 'docs/user/zh-CN/develop/basic/config.md', + source: 'docs/user/develop/basic/config.md', route: 'develop/basic/config.md', - contentLocale: 'zh-CN', label: { root: '插件配置', en: 'Plugin configuration' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 3, }, { - source: 'docs/user/zh-CN/develop/framework/index.md', + source: 'docs/user/develop/framework/index.md', route: 'develop/framework/index.md', - contentLocale: 'zh-CN', label: { root: '插件与生命周期', en: 'Plugin lifecycle' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/framework'], + sourceAliases: ['docs/user/develop/framework'], }, { - source: 'docs/user/zh-CN/develop/framework/service.md', + source: 'docs/user/develop/framework/service.md', route: 'develop/framework/service.md', - contentLocale: 'zh-CN', label: { root: '服务与依赖', en: 'Services and dependencies' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 2, }, { - source: 'docs/user/zh-CN/develop/framework/events.md', + source: 'docs/user/develop/framework/events.md', route: 'develop/framework/events.md', - contentLocale: 'zh-CN', label: { root: '事件系统', en: 'Event system' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 3, }, { - source: 'docs/user/zh-CN/develop/practice/index.md', + source: 'docs/user/develop/practice/index.md', route: 'develop/practice/index.md', - contentLocale: 'zh-CN', label: { root: '能力的三层拆分', en: 'Capability layering' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '实战', en: 'Practice' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/practice'], + sourceAliases: ['docs/user/develop/practice'], }, { - source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', + source: 'docs/user/develop/practice/llm-adapter.md', route: 'develop/practice/llm-adapter.md', - contentLocale: 'zh-CN', label: { root: 'LLM 适配器', en: 'LLM adapter' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '实战', en: 'Practice' }, From 8a33e7cd006eab76000392cef1bbc20d9552b174 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 16 Jul 2026 16:53:25 +0800 Subject: [PATCH 012/132] docs(rfc): propose harness-level goal-based loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce packages/loop as a capability seam covering goal-based (and naturally proactive) loops around the existing agent-loop, deferring time-based scheduling to a future dsh-schedule RFC. Four cordis service seams — loop-as-session, pluggable Evaluator/Budget with EvaluatorSpec tiers and protectedPaths, RoundHandoff, GoalReflector — plus a driver- enforced Default-FAIL contract and reuse of packages/fs policy gate for reward-hacking defense. Bilingual pair. --- docs/rfc/INDEX.md | 1 + .../2026-07-16-harness-level-loop.i18n.yaml | 6 + .../feature/2026-07-16-harness-level-loop.md | 321 ++++++++++++++++++ .../2026-07-16-harness-level-loop.zh.md | 321 ++++++++++++++++++ 4 files changed, 649 insertions(+) create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md create mode 100644 docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..17dcb13f8f 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -14,6 +14,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | | [Developer-owned SDK projects](proposed/feature/2026-07-14-sdk-developer-projects.md) | 2026-07-14 | +| [harness-level goal-based loop](proposed/feature/2026-07-16-harness-level-loop.md) | 2026-07-16 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml new file mode 100644 index 0000000000..fe26d5d8a4 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-16-harness-level-loop.md: 8bc4ecf3734a7e9d82d1f844b3d7186fa6ccafed +2026-07-16-harness-level-loop.zh.md: 4e1b677259b310171098cc78dd174b55c8d69970 diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md new file mode 100644 index 0000000000..8bc4ecf373 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md @@ -0,0 +1,321 @@ +# RFC: harness-level goal-based loop + +Status: proposed + +English | [中文](2026-07-16-harness-level-loop.zh.md) + +## Problem + +`packages/core/agent-loop` runs only the inner loop: reasoning plus tool calls within one turn, ending when the model returns `end_turn`. Its README explicitly writes "No built-in turn budget"—budget is a gap it acknowledges itself. Cross-round scheduling falls on the harness layer: iterating until tests all pass, revising drafts against a rubric, splitting a PRD into beads and driving them one by one, running unattended for a whole night. None of these tasks has a first-class implementation today. + +The existing code offers three "just enough to run" alternatives, none of them adequate: + +| Alternative | Problem | +|---|---| +| A `packages/workflow` script expressing `while (!done)` | The README explicitly writes "No token-budget vocabulary" and "No journaling or resume"; the parent turn blocks until the script settles. Fine for orchestration lasting minutes, unusable for tasks lasting hours | +| An external shell `while :; do dsh …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery | +| The `sendMessage`/`resume` capabilities on the `packages/subagent` seam | The README explicitly writes "Runtime steering and continuation are seam-only capabilities". There is no model-facing consumer, so the model can only start a fresh subagent | + +Three typical use cases. **Automated fix**: a failing test suite in front of you, and you want a process to keep modifying code, running tests, and modifying again against the failure messages, until everything is green or the budget cap is hit. **Rubric-driven iterative revision**: a document, code, or translation must meet a set of scoring criteria; the loop repeatedly adjusts, an independent evaluator scores, and the loop stops when the criteria are met or the round budget is exhausted. **Unattended long runs**: for example, porting a repository from one tech stack to another overnight, kicked off before leaving work and reviewed the next morning, with the budget as the only safety net. Common shape across all three: minutes to hours, evaluator decides success, budget is a hard constraint, and post-run review and recovery are required. + +## Proposal + +**Loops come in four trigger shapes**, distinguished by who starts a round and when: + +| Shape | Who triggers | When | Existing comparable | This RFC | +|---|---|---|---|---| +| **turn-based** | The user sends a message in the session | Every user reply | `packages/core/agent-loop`'s existing reasoning-plus-tools cycle within one turn | Not covered; already implemented | +| **goal-based** | The user or the agent specifies "run until some condition" | One start, evaluator decides when to stop | Claude Code's `/goal`, Codex's `/goal`, the Ralph family | **This RFC covers it** | +| **time-based** | A scheduler | On cron or fixed interval | Claude Code's `/loop` (periodic), `/schedule` | Deferred to a `dsh-schedule` RFC | +| **proactive** | The agent itself | When the agent realizes during reasoning that a loop is needed | The proactive tier in Anthropic ClaudeDevs's four-way taxonomy | **Naturally included** (an agent calling the `loop` tool is already proactive) | + +This RFC only **adds a capability seam `packages/loop/`** for the goal-based shape. Proactive reuses the same `loop` tool—an agent invocation is a trigger by itself, with no extra machinery. Time-based needs an independent scheduler package and belongs to a separate RFC; this RFC only reserves a hook on the cordis leaf trigger surface for the future `dsh-schedule` integration. + +Three packages: + +- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, four built-in service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`), and the event schema +- `@deepseek-ai/dsh-loop-driver`: the default driver implementation +- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh loop` + +The design is organized around four concrete problems, each addressed by an independent cordis service seam: + +1. A long-running loop that goes wrong leaves no systematic diagnosis or recovery. **Loop as an independent session** addresses this. +2. Whether the PASS at loop end is trustworthy determines whether hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face. **Making Evaluator and Budget into service seams** addresses this. +3. Short and long tasks need opposite memory strategies; hardcoding one mode makes the other class of scenario unusable. **Making RoundHandoff into a service seam** addresses this. +4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Making GoalReflector into a service seam** addresses this. + +Beyond the four seams, one principle threads through the whole document: **one loop handles one atomic goal**. Large goals should be split into several small loops chained in sequence, not stuffed into one loop with the evaluator judging multiple things. A rule of thumb for whether granularity is right: if you cannot say what a finished loop actually accomplished, granularity is too large and should be split. Phase 2 adds a `loop_split` model-facing tool so the agent can split an oversized goal itself. + +Terminology: **inner loop** refers to the existing per-turn reasoning-and-tools cycle in `packages/core/agent-loop`; **harness loop** refers to the outer scheduler introduced by this RFC, iterating around the inner loop. This RFC does not modify `agent-loop`, matching AGENTS.md's "Plugins, not loop changes". + +`StopCondition` is a discriminated union with `assertNever` closing the switch: + +```ts +interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + +type StopCondition = + | { kind: 'goal-met'; evidence: EvaluatorReport } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } + | { kind: 'approval-required'; reason: string } + | { kind: 'user-cancel' } + +export {} +``` + +### Loop as an independent session + +Once a long-running loop goes wrong, the user has no systematic diagnostic method. A failure hours in leaves only scattered log files to sift through. Discovering that some middle round went off track and wanting to roll back to re-run means starting over from scratch. An agent wanting to consult its own experience from past loops has no API to reach it. + +The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three capabilities. + +- **Resume from any round**: discover round 78 went off, restart from round 77 with a different prompt or evaluator, no need to start over +- **Post-hoc diagnosis**: through [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md), query "which round did the evaluator start hanging on the same criterion" to locate the stuck point +- **Meta-loop learning**: before starting a new loop, the agent queries its own experience from past loops of the same kind—"have I fixed a similar bug before? Which round did it fail on?" + +Claude Code's and Codex's `/goal` are one-off objects: discarded when the run ends, so the agent starts from zero when facing a similar problem again. + +**Storage and dependency**. A few KB of events per round, roughly 100–500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10–100×, and the README calls this out clearly. **The most critical point**: this section's capabilities have a hard dependency on the not-yet-landed [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md). If that RFC does not land, arbitrary-round resume and query capability degrade to "just grep the JSONL files". If Phase 1 ships before that RFC merges, Phase 1 only guarantees correct event shape and defers the query surface to Phase 2. + +### Pluggable Evaluator and Budget + +A loop's value ultimately depends on whether the final PASS is trustworthy. If the evaluator can be hacked or hallucinates PASS, hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face: the model has the means to talk itself into PASS. Even letting an independent subagent be the evaluator only mitigates the problem; as long as the evaluator is still an LLM, it retains a systematic bias for the same class of content—an independent subagent is a mitigation, not a cure. + +Truly trustworthy evaluation must be a fully non-LLM hard check: shell exit code, static analysis, an external service. The LLM physically cannot touch the evaluation process. But only the user knows which hard check to run: `pytest` commands differ by project, companies have private compliance checkers, some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into. + +Budget is the same story: product-level spending guardrails are opaque, and cannot be adjusted for team policy (personal card, team splitting, per-PR settlement). + +`Evaluator` and `BudgetPolicy` are both exposed as cordis service seams, with users injecting them as plugins. `Goal` must carry an `EvaluatorSpec` at an explicit tier; the driver refuses to start a loop without a paired evaluator—vague goals ("write good code") cannot enter the loop system: + +```ts +interface RubricItem { name: string; description: string } +interface EvaluatorContract { readonly name: string } + +type EvaluatorSpec = { + tier: + | { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0" + | { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence + | { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验) + | { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标 + /** + * 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。 + * 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。 + * 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。 + */ + protectedPaths?: readonly string[] +} + +export {} +``` + +**Why tiers instead of letting the user pass any function?** Tiers force the user, at start time, to declare "which strength of judgment I'm using". A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing strict judgment when they've actually written a fuzzy LLM-judge-style semantic match. In long-run scenarios the cost is hours wasted. + +The four tiers descend in trustworthiness from top to bottom. The top three are fully non-LLM hard checks. `llm-judge` is the weakest tier, only for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this tier cannot defend against targeted adversarial input; long unattended runs require at least one hard-check tier alongside". + +The driver enforces five hard constraints, not delegated to Evaluator implementations. They address three distinct attack surfaces: + +**Preventing "the same agent both generates and self-evaluates"**: + +1. **fresh subagent**: the evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context + +**Preventing the main agent from reward-hacking by modifying evaluator inputs**: + +2. **`protectedPaths` hard isolation**: paths declared in `EvaluatorSpec` (typically test files and evaluator config) are rejected for main-agent writes by the existing policy gate in `packages/fs`, and blocked writes are recorded as `loop/hack-attempt` session events. This is Phase 1's real defense against reward hacking—it seals off the "modify the tests so the evaluator passes" path directly + +**Preventing the evaluator subagent itself from being subverted**: + +3. **read-only tool set**: the evaluator's tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). It defends not against main-agent reward hacking, but against the evaluator being prompted-injected via code embedded in the code being evaluated—so it cannot be tricked into modifying state + +**Preventing the evaluator report itself from deceiving the driver**: + +4. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly +5. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence + +Together, the five decide that evaluator conclusions can only be driven by evidence—not by confidence, and not by the main agent quietly modifying tests. + +**Phase 1 ships three backends**: + +- `loop-evaluator-shell` implements `single-metric`: runs a shell command, `exit 0` = pass +- `loop-evaluator-rubric-judge` implements `llm-judge`: a prewritten rubric plus LLM scoring, soft goals only +- `loop-budget-preflight`: before each round starts, estimate `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`; refuse to start if the estimate exceeds `perRoundUsd`. The estimation model comes from MartinLoop `policy.ts:551-596` + +A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). The `rubric` and `contract` tiers get built-in implementations in Phase 2; Phase 1 only exposes the types so third-party plugins can integrate first. + +**Limitation**: the "read-only tools" the evaluator subagent receives are still shell and fs reads within the same process, and could theoretically be bypassed by prompt injection. Defending against targeted adversarial input requires the two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes), which is a Phase 3 item. See Risks. + +### Pluggable RoundHandoff + +How context passes between rounds is a dilemma. Preserving the full prior conversation (continue) reads more coherently, but the conversation keeps growing and eventually hits the context ceiling, and errors from a prior round contaminate every subsequent round. Starting each round from scratch (fresh) avoids the contamination, but has to re-understand context every time. A 3-round revision loop and an 80-round overnight bug-fix loop need opposite strategies. Claude Code and Codex both hardcode one mode, so users cannot switch by task type. + +Made a service seam: + +```ts +interface RoundContext { loopId: string; round: number } +interface NextRoundSpec { mode: 'fresh' | 'continue' } + +interface RoundHandoff { + buildNextRound(prev: RoundContext): NextRoundSpec +} + +export {} +``` + +Phase 1 ships three backends: + +| Backend | Scenario | Mechanism | +|---|---|---| +| `handoff-fresh-with-summary` (default) | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append | +| `handoff-continue-with-compaction` (recommended middle) | Medium length, 5–20 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point | +| `handoff-continue-raw` (advanced) | ≤5 rounds, short tasks, testing | Plain continuation without truncation | + +**Why default to fresh?** Every long-run loop that actually succeeded (repomirror, Kimi ralph-loop, autoresearch) uses fresh. Placing important loop state outside the context window under driver management is the correct posture for long runs. `handoff-continue-raw` violates this experience, and the README explicitly notes it is not suitable for long runs. + +**Why is only this repo able to build the middle tier?** `handoff-continue-with-compaction` depends on a compaction seam—the competitors don't have one; only this repo's `packages/compact` provides that infrastructure. + +**Why a seam rather than a three-choice flag?** Users can write 20-line plugins expressing hybrid strategies like "continue for the first 5 rounds, then fresh", or "auto-compact once when context hits 50%", without waiting for main-library support. + +**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem. + +### Pluggable GoalReflector + +The goal the user gives at loop start is not always accurate. It may be based on a wrong assumption (asking the agent to implement a feature with a since-deprecated API), it may not be clear enough (the agent discovers a clarification is needed only mid-work), or it may be invalidated by later information. Current loop-execution frameworks treat the goal as a contract frozen at start; the agent can only push down the original path, and the result is exhausting the budget on the wrong direction. + +Made a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal". + +```ts +interface RoundContext { loopId: string; round: number } +interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } + +interface GoalReflector { + reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise +} + +type GoalReflection = + | { kind: 'continue' } // goal 仍有效 + | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal + | { kind: 'stop-for-human'; reason: string } // 需要人拍板 + +export {} +``` + +**Concerns have three sources**, and Phase 1 ships the first two: + +- **Agent-initiated**: via the model-facing tool `loop_flag_concern({ concern, severity })`. An agent that realizes during investigation that "the library the user assumed has been deprecated" can raise directly +- **Driver heuristic**: when budget passes 50% and zero criteria have passed, the driver auto-raises a `no-progress-toward-goal` concern +- **Periodic reflector subagent** (Phase 2): every N rounds, run an independent read-only subagent to re-audit goal validity, following the same isolation approach as the evaluator + +**Response strategy is controlled by the `onGoalConcern` config**. The four settings correspond to different philosophies about loop use; users choose by their team's collaboration style, and the driver takes no default stance: + +- `'stop'` (Phase 1 default): any concern triggers `StopCondition: approval-required`, and a human decides. A loop should never proceed on its own in the face of uncertainty—suitable for cautious teams and for high-impact loop scenarios +- `'notify-continue'` (Phase 1): record a high-priority `loop/goal-concern` session event plus an explicit ACP notification, then continue; a human reviews at the end. The loop internal is not interrupted—suitable for unattended long runs +- `'reflect'` (Phase 2): call `GoalReflector` to decide continue, revise, or stop. Delegates the initial judgment to an independent agent in place of a human—suitable for teams with moderate autonomy +- Not registering a `GoalReflector` and leaving `onGoalConcern` unset = the most hands-off tier: the loop stops only on traditional stop conditions + +**Why default to `stop`?** In unattended scenarios, stopping one extra time is safer than running for hours in the wrong direction. Users who explicitly want unattended can switch to `notify-continue`. + +A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: on resume, one can pick up from the round where the concern surfaced, swap the goal, and re-run—the work of the previous N rounds is not lost. + +**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and resume can select any historical goal version. + +### User surface + +Four trigger surfaces share one driver: + +- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` starts a nested harness loop. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery +- **CLI**: `dsh loop --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage +- **cordis leaf**: declare a resident loop as a leaf in `cordis.yml`, with future `dsh-schedule` RFC integration for periodic triggering +- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session + +The ACP slash command depends on: `packages/ui/acp`'s `available_commands_update` surface is currently unbuilt ([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md)). Once the harness's slash-command infrastructure lands, `/loop` and `/loop-flag-concern` only need to be registered against that infrastructure; the driver and tool interfaces do not change. This RFC reserves the names and specifies the argument shape, but does not commit the infrastructure itself—that belongs to a separate ACP catch-up RFC. + +The default system prompt carries two hard constraints, distributed with every built-in `loop` tool: + +1. No writing of `TODO`, `FAKE`, or `PLACEHOLDER` placeholders to superficially pass the evaluator +2. No writing of empty `try/except` or `catch(_)` blocks so the evaluator ignores errors + +Neither can be stopped at the seam layer; both are prompt-layer conventions. Users may customize the system prompt but the built-in constraints remain. + +### Relationship with existing code + +Direct reuse without modification: + +- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; the evaluator gets the read-only tool set +- The SQLite backend from `packages/session-persistence`—the loop-session persists +- `packages/compact`—the implementation basis for `handoff-continue-with-compaction` +- `packages/todo`—an optional progress representation in single-session continue mode +- If [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) lands first, the loop tool can use it for per-round UI updates + +Not touched: `packages/core/agent-loop` (the inner-loop semantics stay the same); `packages/workflow` (DAG orchestration vs. iterating one goal is an orthogonal relationship; the two READMEs cross-link in their "Related" section to describe the boundary). + +Two dependencies not yet landed: + +- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)—see the limitation paragraph of Loop as an independent session for the mitigation +- The ACP slash-command infrastructure (the `available_commands_update` surface)—see User surface. Before the infrastructure lands, the slash-command trigger is absent while the other three trigger surfaces work as normal + +The one modification to existing code can be deferred to Phase 2: adding a "resume an existing subagent" argument surface to `packages/subagent-tool`, used by the `handoff-continue-*` backends. The underlying `SubagentRun.sendMessage` and `resume` already exist as seam capabilities; only the tool-layer argument entrypoint is missing. If Phase 1 ships only `handoff-fresh-with-summary`, subagent-tool need not be touched at all; Phase 2 adds it. + +### Phasing + +**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the four-tier `EvaluatorSpec` type plus the `protectedPaths` hard isolation (reusing the `packages/fs` policy gate), with built-in implementations for `single-metric` and `llm-judge` and the `rubric` and `contract` types open for integration; Default-FAIL enforcement; three built-in evaluator/budget/handoff backends; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; the default system prompt hard constraints. **Not included**: the session-query surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), the subagent-tool resume change, the stuck detector, the Reflector subagent, the `loop_split` tool, and the built-in implementations of the `rubric` and `contract` tiers. + +**Phase 2**: the query surface; the stuck detector (reproducing OpenHands's five patterns); the subagent-tool resume change (unlocking the two continue tiers of handoff); the Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; the built-in implementations of the `rubric` and `contract` tiers. + +**Phase 3**: agent fleet (N parallel loops for the same goal, best result wins); integration with `dsh-schedule`; two-container evaluator isolation (evaluator definition files entirely inaccessible to the main agent, defending against reward hacking). + +## Alternatives considered + +**Extend `packages/core/agent-loop`**: add an "iterate on end_turn until goal" switch to the inner loop. Rejected—AGENTS.md says "new behavior goes on documented extension seams; changing agent-loop requires updating docs/architecture.md". The harness loop needs state across sessions and across agents; stuffing it into the inner loop tangles session semantics into two mixed layers. + +**Ship a single slash command `/loop` (Claude Code clone)**: minimal implementation. Rejected—the slash-command layer does not resolve the harness/inner boundary; the four design points (queryable session, tiered evaluator, pluggable handoff, pluggable goal reflector) have nowhere to sit at the slash-command layer, and every capability this RFC commits is lost. + +**Fully outsource to `packages/workflow`**: express the loop as a workflow node with a back edge. Rejected—workflow lacks first-class semantics for iteration, StopCondition, and Evaluator; forcing it means the evaluator has to masquerade as a phase, violating the architectural-isolation requirement that the evaluator be independent of the producer; the budget guardrail in workflow is phase-level rather than round-level, and the granularities do not match. + +**Hardcode a binary choice between A (fresh) and B (continue)**: the Ralph school and the LoopTroop school each have strong scenarios. Rejected—Pluggable RoundHandoff proposes a seam plus three built-in backends that cover both schools and allow hybrids. + +**Skip the evaluator seam, ship a few built-ins**: lighter. Rejected—the core value of Pluggable Evaluator and Budget is that team-private evaluators can extend the system. Hardcoding leaves long unattended users no option but to modify the main library. + +**Accept a free function that lacks an `EvaluatorSpec` tier**: allow users to pass any `(result) => boolean`. Rejected—the tier system forces users to declare at start time "which strength of judgment I'm using", the key to preventing quiet regression to a weaker tier. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios. + +**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` + `sqlite-session-query-provider` already provide equivalent capability; the payoff of a new engine is far smaller than the maintenance cost. + +**Fold goal reflection into the Evaluator seam** (have the evaluator return "criteria are impossible"): rejected—it conflates "was the goal achieved" with "is the goal still correct", which are orthogonal concerns. `Evaluator` should stay independent, read-only, and simple. + +**Only add an event for goal-concern, no seam**: lighter. Rejected—the response-strategy family (stop / notify / reflect) is well defined and teams will want to plug in their own, so making it a seam pays off more than it costs. + +**Ship the full Reflector subagent in Phase 1**: more complete. Rejected—`loop_flag_concern` tool plus no-progress heuristic plus the two-policy `onGoalConcern` already covers 80% of scenarios; running an independent subagent every round is expensive, and introducing it on demand in Phase 2 is more sensible. + +**Do not ship `loop_split`; let users split themselves**: Phase 1 already does. Phase 2 adds it because long-run scenarios reveal that agents receiving an oversized goal will run it directly rather than split it, so explicit tool guidance is needed. + +## Acceptance criteria + +- The three packages `packages/loop/{loop,loop-driver,loop-tool}` are built as a capability seam; `dsh-loop` exports only types and registry +- `StopCondition` discrimination covers all branches (unit); `assertNever` closes the switch at compile time +- The four services `Evaluator`, `BudgetPolicy`, `RoundHandoff`, and `GoalReflector` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly) +- `EvaluatorSpec`'s four-tier type converges at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately) +- Default-FAIL fixture: when the evaluator report returns `{criterion, pass: true, evidence: []}`, the driver refuses that criterion flip and records an `evaluator/invalid-report` session event +- Each of the three built-in handoff backends has unit tests plus one e2e: `fresh-with-summary` (runs to pass), `continue-with-compaction` (runs past the token threshold to trigger compaction), `continue-raw` (runs 3 rounds) +- `dsh loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code +- Evaluator isolation fixture: the main agent has fs.write, the evaluator subagent's tool set does not; attempting to call fs.write is rejected by the registry +- protectedPaths fixture: with `EvaluatorSpec.protectedPaths: ["tests/**"]` declared, a main-agent attempt to write `tests/foo.py` is rejected by the `packages/fs` policy gate and recorded as a `loop/hack-attempt` session event, while the evaluator's read of that path succeeds +- Preflight guardrail fixture: inject a mock pricing table to construct a scenario over `perRoundUsd`; the driver refuses to start that round and emits a `budget-cap` StopCondition +- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields a `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues and the event carries an ACP high-priority marker; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) +- The default system prompt hard constraints (no TODO/FAKE/PLACEHOLDER, no empty catch) are distributed with the built-in `loop` tool, and a snapshot covers the prompt content +- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events; when Phase 2 adds the query surface, they are indexable by `loopId` +- The "Related" sections in `packages/loop/README.md` and `packages/workflow/README.md` cross-link and describe the "when to use workflow vs. when to use loop" boundary clearly +- Unit 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene all green; the ACP rendering intent (`generic`) of the new tool has a snapshot + +## Risks + +**Dependency on [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) landing**. The user-visible value of Loop as an independent session (arbitrary-round resume plus meta-loop learning) requires it. The mitigation is in that section's limitation paragraph; Phase 1 does not hard-bind, and the query surface ships in Phase 2. + +**The boundary between `packages/workflow` and loop is a recurring FAQ**. "Is multi-round a loop or a workflow?"—both READMEs must state clearly: workflow is "steps known, agent to run undecided, parallel or serial orchestration"; loop is "agent decided, round count undecided, evaluator decides when to stop". Unclear docs cause users to pick the wrong one. + +**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. **Phase 1 blocks most cases via `protectedPaths`**: evaluator input files (tests, evaluator config) are declared write-forbidden for the main agent via the `packages/fs` policy gate, sealing off the "modify the tests to make the evaluator pass" path directly. It still cannot prevent the agent from learning the evaluator's pattern and evading it in substance (for example, writing code that satisfies the surface pattern but is semantically wrong). Users needing high adversarial strength need Phase 3's two-container approach: the entire evaluator runtime (binary, rubric, dependency libraries) sits in a container that the main agent cannot access, matching what Anthropic patch.py does. + +**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. Mitigation goes through the two default system-prompt hard constraints in User surface; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator are safer. This class of problem cannot be cured at the seam layer. + +**Budget estimation drift**. The pricing table is a constant; the estimate drifts once the model provider changes prices. A conservative approximation is not a bug in itself, but the README notes "actual billing is per usage events; preflight only defends against a single round exploding". + +**Long-run loop log growth**. A 100-round loop reaches MB scale for one session. `logDetail: 'summary'` is a safety net but Phase 1 defaults to `full`; Phase 2 adds summary semantics. + +**Pre-release allows direct evolution**. `SESSION_FORMAT_VERSION=0`; the `LoopRoundEvent` schema can change at any time. Backends reject old formats rather than maintain compatibility, matching the pre-release stance at the top of AGENTS.md. diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md new file mode 100644 index 0000000000..4e1b677259 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md @@ -0,0 +1,321 @@ +# RFC: harness 层 goal-based loop + +Status: proposed + +[English](2026-07-16-harness-level-loop.md) | 中文 + +## 问题 + +`packages/core/agent-loop` 只跑 inner loop:一次 turn 内推理加工具循环,模型返回 `end_turn` 就结束。其 README 明确写「No built-in turn budget」——预算是它自己承认的 gap。跨轮次调度落在 harness 层:跑到测试全绿、按 rubric 反复改稿、把 PRD 拆成 bead 逐个推进、无人值守跑一整晚。这几类任务今天都没有一等公民的实现。 + +现有代码里有三种「能凑合跑」的替代,都不够用: + +| 替代 | 问题 | +|---|---| +| `packages/workflow` 脚本表达 `while (!done)` | README 明写「No token-budget vocabulary」和「No journaling or resume」;父 turn 阻塞到脚本 settle。能跑几分钟的编排,跑不了几小时的长期任务 | +| 外部 shell `while :; do dsh …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | +| `packages/subagent` seam 的 `sendMessage`/`resume` | README 明写「Runtime steering and continuation are seam-only capabilities」。没有 model-facing consumer,模型只能起 fresh 子会话 | + +典型使用场景有三类。**自动化修复**:面前一个失败的测试套件,希望一个进程持续修改代码、跑测试、根据失败信息再修改,直到全绿或触达预算上限。**按 rubric 迭代改稿**:一份文档、代码或翻译需要满足打分标准,循环反复调整、独立评估者打分、直到达标或耗尽轮数。**无人值守长跑**:例如通宵把一个仓库从一种技术栈移植到另一种,下班前启动第二天回来看结果,全程只有预算兜底。三类共同的形态:几分钟到几小时、evaluator 决定成败、预算是硬约束、跑完还需要能回看和恢复。 + +## 提案 + +**Loop 有四种触发形态**,按谁在什么时候启动一轮划分: + +| 形态 | 谁触发 | 何时触发 | 现有对标 | 本 RFC | +|---|---|---|---|---| +| **turn-based** | 用户在会话里发一条消息 | 每一轮用户回复 | `packages/core/agent-loop` 现有一次 turn 内的推理与工具循环 | 不覆盖,已有实现 | +| **goal-based** | 用户或 agent 明确指定「跑到某条件为止」 | 一次启动,evaluator 判停 | Claude Code 的 `/goal`、Codex 的 `/goal`、Ralph 家族 | **本 RFC 覆盖** | +| **time-based** | scheduler | 按 cron 或时间间隔 | Claude Code 的 `/loop`(周期性)、`/schedule` | 延后到 `dsh-schedule` RFC | +| **proactive** | agent 自己 | agent 在推理中意识到需要开一个 loop 时 | Anthropic ClaudeDevs 4 类分类里的 proactive 档 | **本 RFC 自然包含**(agent 调 `loop` tool 就是 proactive) | + +本 RFC 只**新增 capability seam `packages/loop/`** 处理 goal-based 一种。proactive 复用同一 `loop` tool,agent 主动调用即触发,无需额外机制。time-based 需要独立的 scheduler package,属于另一份 RFC 的事情;本 RFC 只在 cordis leaf 触发面预留跟未来 `dsh-schedule` 联动的钩子。 + +三个包: + +- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、四个内置 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`)、事件 schema +- `@deepseek-ai/dsh-loop-driver`:默认 driver 实现 +- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh loop` + +设计围绕四个具体问题展开,每个问题对应一条独立的 cordis service seam: + +1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。 +2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。 +3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。 +4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**GoalReflector 做成 service seam** 解决。 + +四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。 + +术语约定:**inner loop** 指 `packages/core/agent-loop` 一次 turn 的推理与工具循环;**harness loop** 指本 RFC 引入的外层调度器,围绕 inner loop 反复迭代。本 RFC 不改 `agent-loop`,符合 AGENTS.md「Plugins, not loop changes」。 + +`StopCondition` 是 discriminated union,`assertNever` 收口: + +```ts +interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + +type StopCondition = + | { kind: 'goal-met'; evidence: EvaluatorReport } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } + | { kind: 'approval-required'; reason: string } + | { kind: 'user-cancel' } + +export {} +``` + +### Loop 作为独立 session + +长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。 + +Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种能力。 + +- **从任意轮恢复**:发现第 78 轮偏航,从第 77 轮拉起,换 prompt 或换 evaluator 重跑,不必从头 +- **事后诊断**:通过 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 查「哪一轮 evaluator 开始一直挂在同条 criterion 上」定位卡点 +- **元循环学习**:agent 开新 loop 前查自己过往同类 loop 的经验——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 + +Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。 + +**存储与依赖**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。**最关键的一条**:本节能力硬依赖尚未落地的 [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md)。若该 RFC 未落地,任意轮 resume 与查询能力会降级为「只能翻 JSONL 文件」。若 Phase 1 交付时该 RFC 还未 merge,本 Phase 只保证 event 结构正确,query 面延后到 Phase 2。 + +### 可插拔的 Evaluator 与 Budget + +loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。 + +真正可信的评估必须是完全非 LLM 的硬检查:shell exit code、静态分析、外部服务。LLM 物理上碰不到评估过程。但硬检查只有用户自己知道该跑什么:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 + +预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。 + +`Evaluator` 和 `BudgetPolicy` 都作为 cordis service seam 暴露。`Goal` 必须携带一个明确档位的 `EvaluatorSpec`,driver 拒绝启动没有 evaluator 配对的 loop——含糊的目标("把代码写好")不能进入 loop 系统: + +```ts +interface RubricItem { name: string; description: string } +interface EvaluatorContract { readonly name: string } + +type EvaluatorSpec = { + tier: + | { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0" + | { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence + | { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验) + | { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标 + /** + * 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。 + * 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。 + * 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。 + */ + protectedPaths?: readonly string[] +} + +export {} +``` + +**为什么分档,而不是让用户传自由函数?** 档位强制用户在启动时明确「用哪一档强度判成败」。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做严格判定,实际写的是 LLM-judge 那种模糊的语义匹配。长跑场景下代价是几小时白跑。 + +四档从上到下可信度依次降低。前三档都是完全非 LLM 的硬检查。`llm-judge` 是最弱一档,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此档不能挡定向对抗,长跑无人值守场景需至少一档硬检查配合」。 + +Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对付三类不同的攻击面: + +**防「同一个 agent 既生成又自评」**: + +1. **fresh subagent**:evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context + +**防主 agent 通过修改 evaluator 输入来 reward hack**: + +2. **`protectedPaths` 硬隔离**:`EvaluatorSpec` 声明的路径(通常是测试文件、评估配置)由 `packages/fs` 已有的 policy gate 拒绝主 agent 的写请求,记 `loop/hack-attempt` session event。这是 Phase 1 真正挡 reward hacking 的一层——直接封死「改测试让 evaluator 通过」这条路 + +**防 evaluator subagent 自身被 subverted**: + +3. **只读工具集**:evaluator 的 tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。它防的不是主 agent 的 reward hacking,而是 evaluator 读到被 evaluate 的代码里 embed 的 prompt injection 时不会被诱导去改状态 + +**防 evaluator 报告本身欺骗 driver**: + +4. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 +5. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 + +五条一起决定 evaluator 结论只能靠证据推动,无法靠自信推动,也无法靠主 agent 悄悄改测试推动。 + +**Phase 1 内置三个 backend**: + +- `loop-evaluator-shell` 实现 `single-metric`:跑 shell 命令,`exit 0` = pass +- `loop-evaluator-rubric-judge` 实现 `llm-judge`:预写 rubric + LLM 打分,仅软目标 +- `loop-budget-preflight`:每轮启动前估 `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`,超 `perRoundUsd` 拒绝启动。估算模型来自 MartinLoop `policy.ts:551-596` + +`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。`rubric` 与 `contract` 档 Phase 2 补内置实现,Phase 1 只暴露类型让第三方插件先接。 + +**局限**:evaluator subagent 拿到的"只读工具"仍是同一进程的 shell 与 fs 读,理论上仍可能被 prompt injection 绕过。挡定向对抗需要两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路),本 RFC Phase 3 才做。见 风险。 + +### 可插拔的 RoundHandoff + +每轮之间如何传递 context 是一个两难。完整保留之前对话(continue)连续性好,但对话会持续增长最终撞上 context 上限,且上一轮的错误信息会污染后续每一轮。每轮从零开始(fresh)避免污染,但每次需要重新理解上下文。跑 3 轮改稿与跑 80 轮 overnight 修 bug 需要的策略是相反的。Claude Code、Codex 都硬编一种模式,用户没法按任务类型切换。 + +做成 service seam: + +```ts +interface RoundContext { loopId: string; round: number } +interface NextRoundSpec { mode: 'fresh' | 'continue' } + +interface RoundHandoff { + buildNextRound(prev: RoundContext): NextRoundSpec +} + +export {} +``` + +Phase 1 内置三个 backend: + +| Backend | 场景 | 机制 | +|---|---|---| +| `handoff-fresh-with-summary`(默认) | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | +| `handoff-continue-with-compaction`(推荐中间档) | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | +| `handoff-continue-raw`(专业档) | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | + +**为什么默认 fresh?** 所有实际跑成的长跑 loop(repomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。 + +**为什么中间档只有我们能做?** `handoff-continue-with-compaction` 依赖 compaction seam——竞品都没有,只有本仓库 `packages/compact` 提供了这个基础设施。 + +**为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。 + +**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 + +### 可插拔的 GoalReflector + +用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。 + +做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。 + +```ts +interface RoundContext { loopId: string; round: number } +interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } + +interface GoalReflector { + reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise +} + +type GoalReflection = + | { kind: 'continue' } // goal 仍有效 + | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal + | { kind: 'stop-for-human'; reason: string } // 需要人拍板 + +export {} +``` + +**concern 有三种触发来源**,Phase 1 实现前两种: + +- **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise +- **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern +- **周期性 reflector subagent**(Phase 2):每 N 轮独立跑一个只读 subagent 复审 goal 有效性,与 evaluator 独立性遵循同一思路 + +**响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场: + +- `'stop'`(Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景 +- `'notify-continue'`(Phase 1):记 `loop/goal-concern` session event(高优先级)加 ACP 显式提示,继续跑,人在结束时集中审阅。loop 内部不打扰,适合无人值守长跑 +- `'reflect'`(Phase 2):调 `GoalReflector` 决定 continue、revise 还是 stop。委派一个独立 agent 代替人做初步判断,适合中等自主度的团队 +- 不注册 `GoalReflector` 且 `onGoalConcern` 未设 = 最放手档,loop 只在传统 stop condition 触发时停 + +**为什么默认选 `stop`?** 无人值守场景下宁可多停一次也不要在错方向上跑几小时。用户明确要无人值守可切 `notify-continue`。 + +concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:resume 时可以从 concern 出现的那一轮拉起,换 goal 重跑,前 N 轮的工作不丢。 + +**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,resume 时可选任意历史 goal 版本。 + +### 用户面 + +四个触发面共享同一个 driver: + +- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 启动嵌套 harness loop。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 +- **CLI**:`dsh loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 +- **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 +- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 + +ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` 面目前是 unbuilt 状态([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md))。等 harness 的 slash command 基础设施落地,`/loop` 与 `/loop-flag-concern` 只需在该基础设施里注册;driver 与 tool 接口不变。本 RFC 保留名字并给出参数 shape,但不承诺基础设施本身——那属于独立的 ACP 补齐 RFC。 + +默认 system prompt 里有两条硬约束,随所有内置 `loop` tool 一起分发: + +1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过 +2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误 + +这两条不是 seam 层能拦的,是 prompt 层的约定。用户可以自定义 system prompt 但内置约束保留。 + +### 与仓库现有代码的关系 + +直接复用无需修改: + +- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent、evaluator 只读工具集 +- `packages/session-persistence` 的 SQLite backend——loop-session 落盘 +- `packages/compact`——`handoff-continue-with-compaction` 的实现基础 +- `packages/todo`——单会话 continue 模式下作为可选 progress 表达 +- 若 [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) 先落地,loop tool 可用它逐轮 UI 更新 + +不动:`packages/core/agent-loop`(inner loop 语义保持);`packages/workflow`(DAG 编排 vs. 迭代同 goal 是 orthogonal 关系,两个 README 在「Related」段互链说明边界)。 + +依赖尚未落地的两处: + +- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)——见 Loop 作为独立 session 局限段的缓解方案 +- ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作 + +唯一涉及现有代码的改动可延后到 Phase 2:给 `packages/subagent-tool` 增加「续跑已有 subagent」的参数暴露,用于 `handoff-continue-*` 两个 backend。底层 `SubagentRun.sendMessage` 与 `resume` 已作为 seam 能力存在,缺的只是 tool 层的参数入口。若 Phase 1 只上 `handoff-fresh-with-summary`,完全不动 subagent-tool;Phase 2 再补。 + +### 分阶段 + +**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;`EvaluatorSpec` 四档类型 + `protectedPaths` 硬隔离(复用 `packages/fs` policy gate),其中 `single-metric` 与 `llm-judge` 有内置实现,`rubric` 与 `contract` 类型开放待接;Default-FAIL 强制;3 个内置 evaluator/budget/handoff backend;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt 硬约束。**不含**:session-query 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent-tool 续跑改动、stuck 检测器、Reflector subagent、`loop_split` tool、`rubric` 与 `contract` 档的内置实现。 + +**Phase 2**:query 面;stuck 检测器(复现 OpenHands 5 种模式);subagent-tool 续跑改动(解锁 continue 两档 handoff);Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;`rubric` 与 `contract` 档的内置实现。 + +**Phase 3**:agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。 + +## 备选方案 + +**扩 `packages/core/agent-loop`**:给 inner loop 加「iterate on end_turn until goal」开关。拒绝——AGENTS.md「新行为走文档化扩展 seam;改 agent-loop 需要更新 docs/architecture.md」。harness loop 需要跨 session、跨 agent 的状态,塞进 inner loop 会把 session 语义拧成两层混合。 + +**只做一个 slash command `/loop`(Claude Code 复刻)**:实现最简。拒绝——slash-command 层不解决 harness/inner 边界;四条设计要点(可查询 session、分档 evaluator、可插拔 handoff、可插拔 goal reflector)在 slash-command 层没有承载点,本 RFC 承诺的能力全部丢失。 + +**全权外包给 `packages/workflow`**:把 loop 表达成带回边的 workflow 节点。拒绝——workflow 缺 iteration、StopCondition、Evaluator 的一等公民语义。硬用会把 evaluator 冒充成一个 phase,违反 evaluator 独立于 producer 的架构隔离要求;预算护栏在 workflow 是 phase-level 而非 round-level,粒度对不上。 + +**A(fresh)vs. B(continue)硬编二选一**:Ralph 派和 LoopTroop 派各自都有强场景。拒绝——可插拔的 RoundHandoff 提出 seam + 三档内置 backend 涵盖两派并允许 hybrid。 + +**不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。 + +**接受不带 `EvaluatorSpec` 档位的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——档位强制用户在启动时明确「用哪一档强度判成败」,是防止不知不觉滑到弱档的关键。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 + +**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` + `sqlite-session-query-provider` 已能提供等效能力;新引擎收益远小于维护成本。 + +**goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。 + +**goal-concern 只做 event 不做 seam**:更轻。拒绝——响应策略族(stop / notify / reflect)明确,各团队会想插自己的,seam 化投资小于收益。 + +**Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。 + +**不做 `loop_split`,用户自己拆**:Phase 1 已经如此。Phase 2 加是因为长跑场景发现 agent 收到过大 goal 会直接跑而不是自己拆,需要显式工具引导。 + +## 验收标准 + +- `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry +- `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口 +- `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector` 四条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用) +- `EvaluatorSpec` 四档类型编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) +- Default-FAIL fixture:evaluator 报告返回 `{criterion, pass: true, evidence: []}` 时 driver 拒绝该 criterion 翻转、记 `evaluator/invalid-report` session event +- 三个内置 handoff backend 都有单元 + 一个 e2e:`fresh-with-summary`(跑到 pass)、`continue-with-compaction`(跑超 token 阈值触发 compact)、`continue-raw`(跑 3 轮) +- `dsh loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 +- Evaluator 独立性 fixture:主 agent 有 fs.write,evaluator subagent 的 tool set 里没有;试图调 fs.write 被 registry 拒绝 +- protectedPaths fixture:`EvaluatorSpec.protectedPaths: ["tests/**"]` 声明后,主 agent 尝试写 `tests/foo.py` 被 `packages/fs` policy gate 拒绝并记 `loop/hack-attempt` session event,evaluator 侧读该路径正常 +- Preflight 护栏 fixture:注入 mock pricing 表构造超 `perRoundUsd` 的场景,driver 拒绝启动该轮且 emit `budget-cap` StopCondition +- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且事件带 ACP 高优先级标记;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) +- 默认 system prompt 硬约束(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容 +- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现;Phase 2 补 query 面时可按 `loopId` 检索 +- `packages/loop/README.md` 和 `packages/workflow/README.md` 的「Related」段互链清楚「何时用 workflow、何时用 loop」的边界 +- 单元 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene 全绿;新增 tool 的 ACP 渲染意图(`generic`)有 snapshot + +## 风险 + +**依赖 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 落地**。Loop 作为独立 session 的用户可见价值(任意轮 resume + 元循环学习)需要它。缓解在该节局限段;Phase 1 不硬绑,Phase 2 才交付 query 面。 + +**`packages/workflow` 与 loop 的边界是持续答疑热点**。「多轮是 loop 还是 workflow」两个 README 必须写清楚:workflow 是「步骤已知、agent 未定、并串行编排」;loop 是「agent 已定、轮数未定、evaluator 判停」。文档不清晰会让用户混用错档。 + +**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。**Phase 1 靠 `protectedPaths` 挡多数 case**:evaluator 的输入文件(测试、评估配置)通过 `packages/fs` policy gate 声明为主 agent 不可写,直接从「改测试让 evaluator 通过」这条路上封死。但仍无法阻止 agent 学出 evaluator 的模式做实质规避(比如写符合表面 pattern 但语义错的代码)。对抗强度高的用户需要 Phase 3 的两容器方案:evaluator 的整个运行时(二进制、rubric、依赖库)都在主 agent 完全不可访问的容器里,Anthropic patch.py 走的就是这条路。 + +**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。缓解走 用户面 段那两条默认 system prompt 硬约束;用户自定义 evaluator 时若加入「静态检查禁止 TODO 与空 catch」这类规则更稳妥。这类问题不是 seam 层能根治的。 + +**预算估算漂移**。pricing 表是常量,模型调价后估算会飘。护栏保守方向的近似不算 bug,但 README 说明「真实计费以 usage 事件为准,preflight 仅保护单轮爆炸」。 + +**长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`,Phase 2 再补 summary 语义。 + +**pre-release 允许直接演进**。`SESSION_FORMAT_VERSION=0`,`LoopRoundEvent` schema 可随时改;后端拒收旧格式而非兼容,与 AGENTS.md 顶部 pre-release stance 一致。 From ce96104a774120c72097d8a89755ec1eb3cfb001 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:02:15 +0800 Subject: [PATCH 013/132] feat(compact): prune tool results before summarization (round 1) --- docs/agent-lifecycle.md | 2 +- docs/architecture.md | 3 +- docs/capability-seams.md | 5 + docs/config-catalog.md | 16 ++ docs/cordis-catalog/services.md | 14 ++ docs/core-data-structures/compaction.md | 2 +- docs/module-graph.md | 7 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 8 +- ...ction-pressure-and-overflow-recovery.zh.md | 8 +- .../2026-06-18-compaction-capability-seam.md | 15 +- docs/tool-catalog.md | 4 +- examples/coding-agent/README.md | 3 +- examples/coding-agent/composition.md | 5 +- examples/coding-agent/cordis.yml | 4 + examples/coding-agent/tests/harness.ts | 2 + packages/compact/README.md | 5 +- packages/compact/compact-basic/README.md | 9 +- packages/compact/compact-basic/package.json | 7 + packages/compact/compact-basic/src/index.ts | 24 +- .../compact-basic/tests/compact-basic.spec.ts | 147 +++++++++++ .../tests/loader-composition.spec.ts | 10 +- packages/compact/compact-basic/tsconfig.json | 3 +- packages/compact/tool-result-prune/README.md | 50 ++++ .../compact/tool-result-prune/package.json | 40 +++ .../compact/tool-result-prune/src/config.ts | 77 ++++++ .../compact/tool-result-prune/src/index.ts | 157 ++++++++++++ .../compact/tool-result-prune/src/types.ts | 40 +++ .../tests/loader-composition.spec.ts | 67 +++++ .../tests/tool-result-prune.spec.ts | 237 ++++++++++++++++++ .../compact/tool-result-prune/tsconfig.json | 15 ++ .../cordis/tool-cordis/src/api-catalog.ts | 17 ++ .../core/tools/tests/gen-tool-catalog.spec.ts | 5 + packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 11 + .../invariants/tests/invariants.spec.ts | 45 ++++ pnpm-lock.yaml | 31 +++ python/sdk-runtime/package.json | 1 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-tool-catalog.ts | 29 ++- tsconfig.build.json | 1 + tsconfig.json | 1 + 42 files changed, 1093 insertions(+), 52 deletions(-) create mode 100644 packages/compact/tool-result-prune/README.md create mode 100644 packages/compact/tool-result-prune/package.json create mode 100644 packages/compact/tool-result-prune/src/config.ts create mode 100644 packages/compact/tool-result-prune/src/index.ts create mode 100644 packages/compact/tool-result-prune/src/types.ts create mode 100644 packages/compact/tool-result-prune/tests/loader-composition.spec.ts create mode 100644 packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts create mode 100644 packages/compact/tool-result-prune/tsconfig.json diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c6010d4139..274d6d9c87 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -55,7 +55,7 @@ sequenceDiagram The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. -`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative. +`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. diff --git a/docs/architecture.md b/docs/architecture.md index 9750ea598c..d2289ff731 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction | +| `ctx.toolResultPrune` | [`compact/tool-result-prune`](../packages/compact/tool-result-prune/README.md) | optional model-free tool-result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | @@ -106,7 +107,7 @@ Each step renders one prompt assembly. Plugins contribute ordered sections, tool Post-tool context follows all results, preserving call/result adjacency. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Leftover steering becomes next-turn input. `agent/turn-stop` is terminal through close and flush: later steering is discarded, while ordinary queued prompts survive. -When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. It also consumes canonical context overflow at `agent/request-error`, but authorizes retry only after a tool-balanced compaction advances `surface.replaceGeneration`. The same turn signal owns both summarization paths. +When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`. The same turn signal owns both paths. ### Failure Boundaries diff --git a/docs/capability-seams.md b/docs/capability-seams.md index eb59f27f32..816fc0ea3b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -16,6 +16,8 @@ flowchart LR pkg_compact_basic["compact-basic"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] + pkg_tool_result_prune["tool-result-prune"] + svc_toolResultPrune["ctx.toolResultPrune
Model-free tool-result pruning"] pkg_session["session"] svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] @@ -127,6 +129,7 @@ flowchart LR pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_token_meter --> svc_tokenMeter + pkg_tool_result_prune --> svc_toolResultPrune pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -173,6 +176,7 @@ flowchart LR svc_tasks --> pkg_tool_subagent svc_tasks --> pkg_tool_tasks svc_tokenMeter --> pkg_compact_basic + svc_toolResultPrune --> pkg_compact_basic svc_tools --> pkg_acp svc_tools --> pkg_agent_loop svc_tools --> pkg_tool_ask_user @@ -195,6 +199,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | +| `ctx.toolResultPrune` | `core` | [`tool-result-prune`](../packages/compact/tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7adaf791ad..cb266a8119 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -930,6 +930,22 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-tool-result-prune` + +```ts config-catalog +/** Character-budget policy for deterministic tool-result pruning. */ +export interface ToolResultPruneConfig { + /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */ + thresholdChars?: number + /** Maximum leading Unicode code points retained. Defaults to `4096`. */ + headChars?: number + /** Maximum trailing Unicode code points retained. Defaults to `1024`. */ + tailChars?: number +} +``` + +Source: [`packages/compact/tool-result-prune/src/types.ts:4`](../packages/compact/tool-result-prune/src/types.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 951881a8a7..e2fc41b943 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -272,6 +272,20 @@ Types: [Message](../core-data-structures/core.md) Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) +## `ctx.toolResultPrune` — `ToolResultPruneService` + +Deterministic head/middle/tail pruning for current tool-result surface nodes. + +```ts cordis-catalog +measureContent(blocks: readonly ContentBlock[]): number +pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null +pruneSession(session: Session): PruneResult +``` + +Types: [ContentBlock](../core-data-structures/core.md) + +Source: [`packages/compact/tool-result-prune/src/index.ts:39`](../../packages/compact/tool-result-prune/src/index.ts) + ## `ctx.tools` — `ToolRegistry` Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 6d3ae30071..3a008a0b95 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. -Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 990692b6e5..fbe9afd59b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,6 +45,7 @@ flowchart TD subgraph group_compact["packages/compact"] pkg_compact["compact"] pkg_compact_basic["compact-basic"] + pkg_tool_result_prune["tool-result-prune"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -168,6 +169,8 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_tool_result_prune --> pkg_llm + pkg_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web pkg_web_search_deepseek --> pkg_web @@ -185,6 +188,7 @@ flowchart TD pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter + pkg_compact_basic --> pkg_tool_result_prune pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -407,6 +411,7 @@ flowchart TD | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-result-prune`](../packages/compact/tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | @@ -415,7 +420,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter), [`tool-result-prune`](../packages/compact/tool-result-prune) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 7cfb11d29d..bd13a336f1 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 7d68bc32d3860bf5edd94c4eda76922c91ae6af2 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 2315bd4d9ca9b93eb9a8d4850f917e6aa1bc6476 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 99dc7625b8e185464d7a8a1ea8eda5baf0674df7 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 4f84d4435341005c058e32582e6d26b2a9f29bc1 diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 7d68bc32d3..99dc7625b8 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -32,9 +32,9 @@ If cancellation lands after assistant tool calls are durable but before all call `CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. -For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. +For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. -For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. +For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. @@ -44,7 +44,7 @@ The default summarizer still resolves explicit configuration, then the latest lo Lifecycle tests pin post-step ordering after durable tool/context/steering work, content-less and max-token successes, final-adapter dispatch/iterator/in-band boundaries, retry numbering, attempt reset, cancellation, disposal, synthetic tool results, and original error identity. -Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, below-threshold forced overflow, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. +Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. ## Alternatives considered @@ -56,7 +56,7 @@ Compact tests pin low-friction service-wide defaults, actual routed-model select ## Consequences -Pressure now describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. +Pressure describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window or split one indivisible oversized message/tool unit. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 2315bd4d9c..4f84d44353 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -32,9 +32,9 @@ Status: implemented `CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 -对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 +对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 +对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 @@ -44,7 +44,7 @@ Status: implemented 生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 -压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、低于阈值的强制溢出、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 +压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 ## 考虑过的替代方案 @@ -56,7 +56,7 @@ Status: implemented ## 后果 -压力现在描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 +压力描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index ecbf401428..0f210cbd8e 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -18,7 +18,8 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. 2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. -3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. +3. **Model-free companion** — `@deepseek-ai/dsh-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. +4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -34,9 +35,9 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text ### Automatic pressure runs after successful durable step work -The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. +The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, it invokes optional `ctx.toolResultPrune`, remeasures the durable surface, and summarizes only if pruning did not restore safe pressure. -Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic forces one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases; the loop then opens a new numbered step and reconstructs its request from the durable log. No range, no replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). +Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step and reconstructs its request from the durable log. No replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering @@ -110,16 +111,16 @@ Two failure paths, both documented: ## Consequences -- **Packages**: `packages/compact/compact` supplies the interface and `compact-basic` supplies the backend. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. +- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. -- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. +- **`dsh-invariants`** treats fresh appended tool results as executions that require an open step and pending call, while provenance-backed replacements are turn-enclosed surface rewrites. Positional replacement and complete-source checks validate the rewritten node. +- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing -- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, forced below-threshold overflow, generation proof, caps, and original-error preservation. +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, pruning configuration and replay, rich-block ordering, metadata preservation, convergence, both `compact/end` outcomes, open-tail refusal, pruning-only and summarized overflow recovery, generation proof, caps, and original-error preservation. - **Loop:** Tests pin post-step after durable tool results and before `step/end`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. - **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 121bfd09f7..085b165a2f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -5,9 +5,9 @@ Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. -This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator's boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## Tool Package Map diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 9e627324d4..869f9eb625 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -56,6 +56,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | | `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | +| `token-meter`, `tool-result-prune`, `compact-basic` | replay-aware pressure, model-free oversized tool-result pruning, and LLM summary compaction. Pruning runs only after a compaction trigger qualifies and can avoid the summarization call | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | @@ -66,7 +67,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads - `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. - `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. - `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. +- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so automatic pruning or summary compaction fires mid-session. It verifies the world: a replayable surface replacement lands, summary brackets are complete when summarization is needed, the surface shrinks, and the agent still produces a correct final answer. - `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event. These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt → no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay). diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index c9a68810d2..5f35097603 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -3,7 +3,7 @@ # Coding Agent App Composition -The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. +The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package. ```mermaid flowchart LR @@ -25,6 +25,8 @@ flowchart LR bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_coding_token_meter + plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-tool-result-prune"] + cfg --> plugin_coding_tool_result_prune plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_coding_compact_basic plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -58,6 +60,7 @@ flowchart LR | `bash` | `@deepseek-ai/dsh-bash-local` | | `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | +| `tool-result-prune` | `@deepseek-ai/dsh-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 2e9aa17786..ba86b99e81 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -50,6 +50,10 @@ - id: token-meter name: '@deepseek-ai/dsh-token-meter' +# Prune oversized tool output without a model call before summary compaction. +- id: tool-result-prune + name: '@deepseek-ai/dsh-tool-result-prune' + # Summarize an older range after measured pressure or a canonical provider overflow. # Service-wide policy provides pressure, retention, and one overflow-retry default. - id: compact-basic diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index cf79809f0c..6c06202a30 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -12,6 +12,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' +import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -68,6 +69,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio // backend, with a lower context window so a short real session crosses the threshold. if (options.compact !== undefined) { await ctx.plugin(TokenMeterService, options.tokenMeter) + await ctx.plugin(ToolResultPruneService) await ctx.plugin(BasicCompactService, options.compact) } // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the diff --git a/packages/compact/README.md b/packages/compact/README.md index b5f5987571..e251bfe442 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -1,11 +1,12 @@ # compact/ — compaction capability family -A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages. +A compaction capability family (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages. | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | | `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 2afd955f35..75be39aa1b 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,12 +9,13 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. +- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. -- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. `summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. @@ -49,15 +50,15 @@ export function apply(ctx: Context): void { } ``` -Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +Loading the plugin registers `ctx.compact`. Add [`dsh-tool-result-prune`](../tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. ## Model Experience ### Conversation history -**What the model sees**: After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units. +**What the model sees**: After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. -**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. +**Token effect**: Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget. #### Conversation checkpoint preamble diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index d50fa45777..55ca301878 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -27,8 +27,14 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", + "@deepseek-ai/dsh-tool-result-prune": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-tool-result-prune": { + "optional": true + } + }, "dependencies": { "schemastery": "^3.18.0" }, @@ -43,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index b694b08194..91e8ad0cea 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -12,6 +12,8 @@ import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +// Type-only: makes the optional sibling service available to `ctx.get()`. +import type {} from '@deepseek-ai/dsh-tool-result-prune' import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' @@ -111,9 +113,9 @@ export class BasicCompactService extends CompactService { return next() } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited. - if (signal.aborted || result === null + if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next() - logResult(result, 'context overflow recovery') + if (result !== null) logResult(result, 'context overflow recovery') return { action: 'retry' } }) } @@ -142,7 +144,7 @@ export class BasicCompactService extends CompactService { * @param agent - agent whose latest durable routed request is measured. * @param trigger - normal post-step pressure or context-overflow recovery. * @param signal - live turn cancellation signal forwarded to summarization. - * @returns the latest compaction result, or `null` when no check/work applies. + * @returns the latest summary compaction result, or `null` when no summary ran. */ override async compactIfNeeded( agent: Agent, @@ -152,15 +154,25 @@ export class BasicCompactService extends CompactService { const model = routedModel(agent.session) if (model === undefined) return null const meter = this.ctx.tokenMeter + const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) + let measurement = meter.measure(agent.session) + if (trigger === 'pressure' && measurement.totalTokens < threshold) return null + + // Pruning is optional so compact-basic remains independently composable. + // Once either trigger qualifies, land the model-free pass before choosing + // a summary range, then remeasure through the singleton replay fold. + const prune = this.ctx.get('toolResultPrune') + if (prune !== undefined) { + prune.pruneSession(agent.session) + measurement = meter.measure(agent.session) + } + if (trigger === 'context-overflow') { - const measurement = meter.measure(agent.session) const range = selectCompactableRange(agent.session, measurement, 0) if (range === null) return null return this.compactRegion(agent.session, range.start, range.end, agent, signal) } - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) - let measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return null let result: CompactionResult | null = null diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3d049608b7..190fb026e7 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,6 +9,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal @@ -94,6 +95,42 @@ function toolConversation(): Session { return session } +/** One closed routed tool step followed by an open turn for rewrite events. */ +function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session { + const session = new Session(SessionId(`oversized-tool-${chars}`)) + const callId = CallId('oversized') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + if (withCompactablePrompt) { + session.append('user/message', { + content: [{ type: 'text', text: 'older history '.repeat(200) }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + } + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { model: MODEL } }, + reason: 'initial', + }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, + step: 1, + callId, + content: [{ type: 'text', text: 'X'.repeat(chars) }], + isError: false, + meta: { presentation: 'preserved' }, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + return session +} + class TestCompactService extends BasicCompactService { summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }] summaryModel = 'summary-model' @@ -401,6 +438,78 @@ describe('pressure measurement and retention', () => { }) }) +describe('optional model-free tool-result pruning', () => { + const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 } + + it('does not prune a below-pressure session opportunistically', async () => { + const ctx = createContext(10_000) + const prune = new ToolResultPruneService(ctx, pruneConfig) + const compact = new TestCompactService(ctx, { + auto: false, + thresholdRatio: 0.8, + retainTokens: 100, + }) + const session = oversizedToolResult() + const pruneSession = vi.spyOn(prune, 'pruneSession') + + expect(await compactIfNeeded(compact, session)).toBeNull() + expect(pruneSession).not.toHaveBeenCalled() + expect(compact.calls).toHaveLength(0) + expect(session.surface.replaceGeneration).toBe(0) + }) + + it('skips LLM summarization when pruning alone clears pressure', async () => { + const ctx = createContext(1_000) + void new ToolResultPruneService(ctx, pruneConfig) + const compact = new TestCompactService(ctx, { + auto: false, + thresholdRatio: 0.5, + retainTokens: 50, + }) + const session = oversizedToolResult() + + expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500) + expect(await compactIfNeeded(compact, session)).toBeNull() + expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500) + expect(compact.calls).toHaveLength(0) + expect(session.surface.replaceGeneration).toBe(1) + }) + + it('summarizes the pruned surface when pruning is insufficient', async () => { + const ctx = createContext(2_000) + void new ToolResultPruneService(ctx, pruneConfig) + const compact = new TestCompactService(ctx, { + auto: false, + thresholdRatio: 0.5, + retainTokens: 50, + }) + const session = toolConversation() + + expect(await compactIfNeeded(compact, session)).not.toBeNull() + expect(compact.calls).toHaveLength(1) + expect(compact.calls[0]!.text).toContain('tool result middle pruned') + expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300)) + }) + + it('retains the original compact-basic behavior without the optional plugin', async () => { + const ctx = createContext(2_000) + const compact = new TestCompactService(ctx, { + auto: false, + thresholdRatio: 0.5, + retainTokens: 50, + }) + const session = oversizedToolResult(3_000, true) + + expect(await compactIfNeeded(compact, session)).not.toBeNull() + expect(compact.calls).toHaveLength(1) + const original = session.events.find(event => event.type === 'tool/result') + expect(original?.type === 'tool/result' && original.data.content[0]) + .toEqual({ type: 'text', text: 'X'.repeat(3_000) }) + expect(session.events.filter(event => + event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) + }) +}) + describe('compaction region transaction', () => { it('rejects an agent that does not own the exact target session before mutation', async () => { const compact = service() @@ -875,6 +984,44 @@ describe('automatic listener and loader composition', () => { expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true) }) + it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => { + const ctx = createContext(10_000) + void new ToolResultPruneService(ctx, { + thresholdChars: 100, + headChars: 20, + tailChars: 10, + }) + const compact = new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 900, + }) + const session = oversizedToolResult() + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(session.surface.replaceGeneration).toBe(1) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) + expect(compact.calls).toHaveLength(0) + }) + + it('continues overflow recovery with summarization on the pruned surface', async () => { + const ctx = createContext(10_000) + void new ToolResultPruneService(ctx, { + thresholdChars: 100, + headChars: 20, + tailChars: 10, + }) + const compact = new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 900, + }) + const session = toolConversation() + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(compact.calls).toHaveLength(1) + expect(compact.calls[0]!.text).toContain('tool result middle pruned') + }) + it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => { const ctx = createContext() void new TestCompactService(ctx, { diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index b13e8f8b67..7294224452 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' +import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' let root: string | undefined let context: Context | undefined @@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmService], ['@deepseek-ai/dsh-token-meter', TokenMeterService], + ['@deepseek-ai/dsh-tool-result-prune', ToolResultPruneService], ['@deepseek-ai/dsh-compact-basic', BasicCompactService], ]) context.loader.internal = { @@ -50,12 +52,17 @@ async function loadYaml(lines: readonly string[]): Promise { } describe('real Loader composition', () => { - it('loads the flat token-meter and compact-basic YAML shape', async () => { + it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-token-meter'", ' config:', ' contextWindow: 4096', + "- name: '@deepseek-ai/dsh-tool-result-prune'", + ' config:', + ' thresholdChars: 100', + ' headChars: 20', + ' tailChars: 10', "- name: '@deepseek-ai/dsh-compact-basic'", ' config:', ' thresholdRatio: 0.5', @@ -68,6 +75,7 @@ describe('real Loader composition', () => { .map(entry => entry.options.name) expect(unloaded).toEqual([]) expect(loaded.tokenMeter.contextWindow).toBe(4096) + expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) expect((loaded.compact as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 0103ad82a8..5dd00b83f1 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, { "path": "../../core/agent" }, - { "path": "../compact" } + { "path": "../compact" }, + { "path": "../tool-result-prune" } ] } diff --git a/packages/compact/tool-result-prune/README.md b/packages/compact/tool-result-prune/README.md new file mode 100644 index 0000000000..e6d50f229c --- /dev/null +++ b/packages/compact/tool-result-prune/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-result-prune + +The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log. + +This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable. + +## Service API + +`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection. + +`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster. + +Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement. + +## Config + +Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable. + +| Key | Required | Meaning | +|---|---|---| +| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. | +| `headChars` | no (default `4096`) | Leading Unicode code points retained. | +| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. | + +All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting. + +## Usage + +```ts +import type { Context } from 'cordis' +import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' + +export function apply(ctx: Context): void { + ctx.plugin(ToolResultPruneService) +} +``` + +## Model Experience + +### Pruned tool result + +**What the model sees**: Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original. + +**Token effect**: Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface. + +## Known Limitations and Deferred Work + +- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure. +- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important. +- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation. diff --git a/packages/compact/tool-result-prune/package.json b/packages/compact/tool-result-prune/package.json new file mode 100644 index 0000000000..6b7ac89742 --- /dev/null +++ b/packages/compact/tool-result-prune/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-tool-result-prune", + "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/compact/tool-result-prune/src/config.ts b/packages/compact/tool-result-prune/src/config.ts new file mode 100644 index 0000000000..a2d33ac76e --- /dev/null +++ b/packages/compact/tool-result-prune/src/config.ts @@ -0,0 +1,77 @@ +/** Configuration resolution for deterministic tool-result pruning. */ + +import { deepFreeze } from '@deepseek-ai/dsh-llm' +import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts' + +/** Fixed marker substituted for every removed middle span. */ +export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n' + +/** Low-friction defaults for coding-agent tool output. */ +export const DEFAULTS: ResolvedConfig = deepFreeze({ + thresholdChars: 8192, + headChars: 4096, + tailChars: 1024, +}) + +const CONFIG_KEYS: ReadonlySet = new Set([ + 'thresholdChars', + 'headChars', + 'tailChars', +]) + +/** + * Count Unicode code points without splitting surrogate pairs. + * @param text - text to measure. + * @returns the Unicode code-point count. + */ +export function codePointLength(text: string): number { + return Array.from(text).length +} + +/** + * Resolve and validate pruning budgets. + * @param config - raw plugin configuration. + * @returns a detached deeply immutable configuration. + */ +export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig { + for (const key of Object.keys(config)) { + if (!CONFIG_KEYS.has(key)) { + throw new Error( + `ToolResultPruneConfig: unknown key "${key}" ` + + '(allowed: thresholdChars, headChars, tailChars)', + ) + } + } + + const resolved: ResolvedConfig = { + thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars, + headChars: config.headChars ?? DEFAULTS.headChars, + tailChars: config.tailChars ?? DEFAULTS.tailChars, + } + assertPositiveInteger('thresholdChars', resolved.thresholdChars) + assertNonNegativeInteger('headChars', resolved.headChars) + assertNonNegativeInteger('tailChars', resolved.tailChars) + + const emittedChars = resolved.headChars + + codePointLength(PRUNE_MARKER) + + resolved.tailChars + if (emittedChars > resolved.thresholdChars) { + throw new Error( + `ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) ` + + `must be at most thresholdChars (${resolved.thresholdChars})`, + ) + } + return deepFreeze(structuredClone(resolved)) +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`) + } +} + +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`) + } +} diff --git a/packages/compact/tool-result-prune/src/index.ts b/packages/compact/tool-result-prune/src/index.ts new file mode 100644 index 0000000000..287b0f2500 --- /dev/null +++ b/packages/compact/tool-result-prune/src/index.ts @@ -0,0 +1,157 @@ +/** + * Replay-safe, model-free tool-result pruning service. + * + * @module @deepseek-ai/dsh-tool-result-prune + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts' +import type { + PrunedEntry, + PruneResult, + ResolvedConfig, + ToolResultPruneConfig, +} from './types.ts' + +export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts' +export type { + PrunedEntry, + PruneResult, + ResolvedConfig, + ToolResultPruneConfig, +} from './types.ts' + +declare module 'cordis' { + interface Context { + toolResultPrune: ToolResultPruneService + } +} + +interface SnapshotCandidate { + readonly seq: number + readonly event: SessionEvent<'tool/result'> +} + +/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */ +export class ToolResultPruneService extends Service { + static Config: z = z.object({ + thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars), + headChars: z.number().step(1).min(0).default(DEFAULTS.headChars), + tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars), + }) + + /** Resolved and immutable character budgets. */ + readonly config: ResolvedConfig + + constructor(ctx: Context, config: ToolResultPruneConfig = {}) { + super(ctx, 'toolResultPrune') + this.config = resolveConfig(config) + } + + /** + * Measure text content in Unicode code points; non-text blocks cost zero. + * @param blocks - tool-result content to measure. + * @returns total Unicode code points across text blocks. + */ + measureContent(blocks: readonly ContentBlock[]): number { + let chars = 0 + for (const block of blocks) { + if (block.type === 'text') chars += codePointLength(block.text) + } + return chars + } + + /** + * Replace an over-budget text middle while retaining rich-block order. + * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained + * boundary cannot split a surrogate pair. Grapheme clusters may still split. + * @param blocks - original tool-result content. + * @returns pruned content, or `null` when the text is within budget. + */ + pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null { + const totalChars = this.measureContent(blocks) + if (totalChars <= this.config.thresholdChars) return null + + const removedStart = this.config.headChars + const removedEnd = totalChars - this.config.tailChars + const pruned: ContentBlock[] = [] + let consumed = 0 + let markerInserted = false + + for (const block of blocks) { + if (block.type !== 'text') { + pruned.push(block) + continue + } + + const points = Array.from(block.text) + const blockStart = consumed + const blockEnd = blockStart + points.length + const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart)) + const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart)) + const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart + const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : '' + if (marker.length > 0) markerInserted = true + const text = points.slice(0, headEnd).join('') + + marker + + points.slice(tailStart).join('') + if (text.length > 0) pruned.push({ ...block, text }) + consumed = blockEnd + } + + /* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */ + if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span') + const charsAfter = this.measureContent(pruned) + /* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */ + if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) { + throw new Error('tool-result prune: replacement must be smaller and within threshold') + } + return pruned + } + + /** + * Prune every over-budget tool result from one stable current-surface snapshot. + * Each replacement preserves the complete event data except for `content`, + * and points at the shadowed node for durable provenance and replay. + * @param session - session whose current surface is rewritten. + * @returns landed replacements and aggregate Unicode-code-point savings. + */ + pruneSession(session: Session): PruneResult { + const candidates: SnapshotCandidate[] = [] + for (const node of [...session.surface.nodes]) { + const event = session.events[node.seq] + /* v8 ignore next -- surface seqs are validated contiguous log references. */ + if (event?.type === 'tool/result') candidates.push({ seq: node.seq, event }) + } + + const pruned: PrunedEntry[] = [] + let charsRemoved = 0 + for (const { seq, event } of candidates) { + const content = this.pruneContent(event.data.content) + if (content === null) continue + const charsBefore = this.measureContent(event.data.content) + const charsAfter = this.measureContent(content) + const replacement = session.append('tool/result', { + ...event.data, + content, + }, { + surfaceOp: { op: 'replace', start: seq, end: seq }, + sourceEventSeqs: [seq], + }) + pruned.push({ + originalSeq: seq, + replacementSeq: replacement.seq, + callId: event.data.callId, + charsBefore, + charsAfter, + }) + charsRemoved += charsBefore - charsAfter + } + return { pruned, charsRemoved } + } +} + +export default ToolResultPruneService diff --git a/packages/compact/tool-result-prune/src/types.ts b/packages/compact/tool-result-prune/src/types.ts new file mode 100644 index 0000000000..f9dd846f35 --- /dev/null +++ b/packages/compact/tool-result-prune/src/types.ts @@ -0,0 +1,40 @@ +import type { CallId } from '@deepseek-ai/dsh-llm' + +/** Character-budget policy for deterministic tool-result pruning. */ +export interface ToolResultPruneConfig { + /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */ + thresholdChars?: number + /** Maximum leading Unicode code points retained. Defaults to `4096`. */ + headChars?: number + /** Maximum trailing Unicode code points retained. Defaults to `1024`. */ + tailChars?: number +} + +/** Validated, detached, deeply immutable pruning configuration. */ +export interface ResolvedConfig { + readonly thresholdChars: number + readonly headChars: number + readonly tailChars: number +} + +/** Provenance and size accounting for one landed surface replacement. */ +export interface PrunedEntry { + /** Full-fidelity tool-result event shadowed by the replacement. */ + readonly originalSeq: number + /** Newly appended pruned tool-result event. */ + readonly replacementSeq: number + /** Tool call shared by the original and replacement. */ + readonly callId: CallId + /** Original text size in Unicode code points. */ + readonly charsBefore: number + /** Replacement text size in Unicode code points. */ + readonly charsAfter: number +} + +/** Aggregate outcome of one stable-surface pruning pass. */ +export interface PruneResult { + /** Replacements in the snapshotted surface order. */ + readonly pruned: readonly PrunedEntry[] + /** Total Unicode code points removed across replacements. */ + readonly charsRemoved: number +} diff --git a/packages/compact/tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/tool-result-prune/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..fef9326d2a --- /dev/null +++ b/packages/compact/tool-result-prune/tests/loader-composition.spec.ts @@ -0,0 +1,67 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('tool-result-prune real Loader composition', () => { + it('loads and resolves the flat YAML plugin shape', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-tool-result-prune-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-tool-result-prune'", + ' config:', + ' thresholdChars: 100', + ' headChars: 20', + ' tailChars: 10', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier !== '@deepseek-ai/dsh-tool-result-prune') { + throw new Error(`unexpected Loader import: ${specifier}`) + } + return ToolResultPruneService + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService) + expect(context.toolResultPrune.config).toEqual({ + thresholdChars: 100, + headChars: 20, + tailChars: 10, + }) + }) + + it('rejects stale config after plugin schema normalization', async () => { + context = new Context() + await expect(context.plugin(ToolResultPruneService, { + maxChars: 100, + } as never)).rejects.toThrow(/unknown key "maxChars"/) + }) +}) diff --git a/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts new file mode 100644 index 0000000000..235ecdab52 --- /dev/null +++ b/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import ToolResultPruneService, { + codePointLength, + DEFAULTS, + PRUNE_MARKER, + resolveConfig, +} from '@deepseek-ai/dsh-tool-result-prune' +import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-tool-result-prune' + +const SMALL: ToolResultPruneConfig = { + thresholdChars: 50, + headChars: 4, + tailChars: 3, +} + +function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService { + return new ToolResultPruneService(new Context(), config) +} + +function appendToolStep( + session: Session, + turn: number, + call: string, + content: ContentBlock[], + extra: Record = {}, +): number { + const callId = CallId(call) + session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('step/start', { turn, step: 1 }) + session.append('assistant/message', { + turn, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' }) + const result = session.append('tool/result', { + turn, + step: 1, + callId, + content, + isError: false, + ...extra, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + return result.seq +} + +describe('tool-result pruning configuration', () => { + it('resolves detached immutable defaults and partial overrides', () => { + const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 } + const resolved = resolveConfig(raw) + raw.headChars = 1 + expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 }) + expect(Object.isFrozen(resolved)).toBe(true) + expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 }) + expect(Object.isFrozen(DEFAULTS)).toBe(true) + }) + + it('rejects stale keys, invalid scalars, and an output budget above threshold', () => { + const bad = [ + [{ thresholdChars: 0 }, /thresholdChars .* positive integer/], + [{ headChars: -1 }, /headChars .* non-negative integer/], + [{ tailChars: 1.5 }, /tailChars .* non-negative integer/], + [{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/], + [{ threshold: 10 }, /unknown key "threshold"/], + ] as Array<[unknown, RegExp]> + for (const [config, pattern] of bad) { + expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern) + } + }) +}) + +describe('ToolResultPruneService content transform', () => { + it('measures text code points only and skips content within threshold', () => { + const prune = service() + const blocks = [ + { type: 'text', text: 'a😀b' }, + { type: 'reasoning', text: 'not measured' }, + ] satisfies ContentBlock[] + expect(prune.measureContent(blocks)).toBe(3) + expect(prune.pruneContent(blocks)).toBeNull() + expect(codePointLength('a😀b')).toBe(3) + }) + + it('keeps configured head and tail without splitting surrogate pairs', () => { + const prune = service() + const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }]) + expect(result).toEqual([{ + type: 'text', + text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`, + }]) + expect(prune.measureContent(result!)).toBeLessThanOrEqual(50) + expect(result![0]).toMatchObject({ type: 'text' }) + expect((result![0] as { text: string }).text).not.toContain('\uFFFD') + }) + + it('preserves non-text blocks and their relative ordering across removed text', () => { + const prune = service() + const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' } + const call: ContentBlock = { + type: 'tool-call', + id: CallId('nested'), + name: 'nested', + arguments: '{}', + } + const result = prune.pruneContent([ + { type: 'text', text: 'A'.repeat(40) }, + reasoning, + { type: 'text', text: 'B'.repeat(30) }, + call, + { type: 'text', text: 'C'.repeat(30) }, + ]) + expect(result).toEqual([ + { type: 'text', text: `AAAA${PRUNE_MARKER}` }, + reasoning, + call, + { type: 'text', text: 'CCC' }, + ]) + expect(prune.measureContent(result!)).toBeLessThanOrEqual(50) + }) + + it('supports zero-sized head and tail while still shrinking', () => { + const prune = service({ + thresholdChars: codePointLength(PRUNE_MARKER), + headChars: 0, + tailChars: 0, + }) + const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }]) + expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }]) + expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars) + }) +}) + +describe('ToolResultPruneService session transaction', () => { + it('prunes a stable snapshot, preserves all data, and records provenance', () => { + const session = new Session(SessionId('preserve')) + const originalSeq = appendToolStep(session, 1, 'one', [{ + type: 'text', + text: 'x'.repeat(100), + }], { + isError: true, + error: { name: 'ExitError', code: 'EXIT_1' }, + meta: { diff: ['a', 'b'] }, + futureField: { nested: true }, + }) + session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + + const result = service().pruneSession(session) + expect(result.pruned).toHaveLength(1) + expect(result.charsRemoved).toBeGreaterThan(0) + const entry = result.pruned[0]! + expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 }) + expect(entry.charsAfter).toBeLessThanOrEqual(50) + + const original = session.events[originalSeq]! + const replacement = session.events[entry.replacementSeq]! as SurfaceEvent + expect(original).toMatchObject({ + type: 'tool/result', + data: { content: [{ type: 'text', text: 'x'.repeat(100) }] }, + }) + expect(replacement).toMatchObject({ + type: 'tool/result', + data: { + turn: 1, + step: 1, + callId: CallId('one'), + isError: true, + error: { name: 'ExitError', code: 'EXIT_1' }, + meta: { diff: ['a', 'b'] }, + futureField: { nested: true }, + }, + surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, + sourceEventSeqs: [originalSeq], + }) + expect(session.surface.nodes.some(node => node.seq === originalSeq)).toBe(false) + }) + + it('prunes multiple results, skips short ones, and converges in one pass', () => { + const session = new Session(SessionId('multiple')) + appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) + appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }]) + appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }]) + session.append('turn/start', { + turn: 4, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const prune = service() + const first = prune.pruneSession(session) + const second = prune.pruneSession(session) + expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')]) + expect(first.charsRemoved).toBe( + first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0), + ) + expect(second).toEqual({ pruned: [], charsRemoved: 0 }) + }) + + it('replays to the identical pruned model messages', () => { + const session = new Session(SessionId('replay')) + appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) + session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + service().pruneSession(session) + const replay = new Session(session.id, [...session.events]) + expect(replay.deriveMessages()).toEqual(session.deriveMessages()) + expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) + }) + + it('runs under real invariants between closed steps but not outside a turn', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(Invariants) + const prune = new ToolResultPruneService(ctx, SMALL) + const session = ctx.sessions.create(SessionId('invariants')) + appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) + expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/) + session.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + expect(() => prune.pruneSession(session)).not.toThrow() + }) +}) diff --git a/packages/compact/tool-result-prune/tsconfig.json b/packages/compact/tool-result-prune/tsconfig.json new file mode 100644 index 0000000000..e021fa336e --- /dev/null +++ b/packages/compact/tool-result-prune/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 89103923e2..e257991832 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -230,6 +230,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'estimateMessage(message: Message): number', ], }, + { + key: 'toolResultPrune', + summary: 'Deterministic head/middle/tail pruning for current tool-result surface nodes.', + methods: [ + 'measureContent(blocks: readonly ContentBlock[]): number', + 'pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null', + 'pruneSession(session: Session): PruneResult', + ], + }, { key: 'tools', summary: 'Tool registry and execution pipeline.', @@ -793,6 +802,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', }, + { + name: 'PrunedEntry', + declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}', + }, + { + name: 'PruneResult', + declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}', + }, { name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ba64f6326..8657dca3ca 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -69,6 +69,11 @@ describe('gen-tool-catalog assertManifestComplete', () => { // is unlisted, so the guard must fire and name them. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) + try { + assertManifestComplete([]) + } catch (error) { + expect(String(error)).not.toContain('tool-result-prune') + } }) }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 43e2b93bef..5ef2139db2 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,7 +31,7 @@ Session log (per session): - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. -- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal). +- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call, while a provenance-backed single-node `replace` is a turn-enclosed surface rewrite of an already-executed result. A `tool/call` may still have no result when the execution pipeline throws. - **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance. Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 14134984ba..f33f7b4cec 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -232,6 +232,17 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr break } case 'tool/result': { + // A replacement rewrites an already-executed result whose recorded + // turn/step can be closed. Surface provenance above validates the rewrite; + // only fresh appends consume an open step's pending call. + if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') { + if (trace.openTurn === null) { + throw new InvariantError( + 'tool/result surface replacement appended outside any open turn', + ) + } + break + } requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) // A result needs a prior matching call in the same step. (The converse // does NOT hold: a call may have no result — a throwing tool-execution diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index af84c569b9..986cd93e7c 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -189,6 +189,19 @@ describe('session-log invariants', () => { .toThrow(/no prior tool\/call/) }) + it('keeps fresh tool-result appends open-step and pending-call checked', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('closed'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/) + }) + it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -487,6 +500,38 @@ describe('surface invariants', () => { // no throw — well-formed replace op }) + it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('tool/call', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + name: 'echo', + arguments: '{}', + }) + const original = session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + content: [{ type: 'text', text: 'original' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + + expect(() => session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: 'pruned' }], + }, { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).not.toThrow() + }) + it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { const { ctx } = await setup() const session = ctx.sessions.create() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 334a95346d..b0935bde01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-result-prune': + specifier: workspace:^ + version: link:../tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -263,6 +266,31 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/compact/tool-result-prune: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/context/time-context: dependencies: schemastery: @@ -2112,6 +2140,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 297a76d8d5..a436f859fc 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e532eaa3c3..db6acf2c43 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -94,6 +94,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['compact-basic'], note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.', }, + { + key: 'toolResultPrune', + pkg: 'tool-result-prune', + title: 'Model-free tool-result pruning', + mode: 'core', + consumers: ['compact-basic'], + note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.', + }, { key: 'sessions', pkg: 'session', @@ -415,7 +423,7 @@ const APP_EXAMPLES = [ title: 'Coding Agent App Composition', label: 'examples/coding-agent', config: 'examples/coding-agent/cordis.yml', - summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', + summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.', }, { id: 'cordis', @@ -851,7 +859,7 @@ function renderLifecycle(): string { '', 'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.', '', - '`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.', + '`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', '', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3e3a0ce81b..4a0efe66a7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,8 +1,9 @@ /** * Generate `docs/tool-catalog.md` from schemas collected by booting each tool * plugin. Runtime registration is the source of truth for computed schemas; - * the manifest is checked against every on-disk `tool-*` package. `--check` - * verifies the committed artifact. Rationale and ownership live in + * the manifest is checked against every on-disk model-facing `tool-*` package; + * non-model service packages with that prefix are explicitly excluded. + * `--check` verifies the committed artifact. Rationale and ownership live in * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ @@ -38,6 +39,9 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +/** `tool-*` leaves that are runtime services, not contributors to `ctx.tools`. */ +const NON_MODEL_TOOL_PACKAGES = new Set(['tool-result-prune']) + /** * Tool package plus its hand-maintained boot recipe. The caller mounts the * prompt and registry; each recipe supplies only package-specific seams and @@ -77,9 +81,10 @@ interface ToolPackage { } /** - * The boot manifest: every shipped tool package (a `tool-*` leaf under - * `packages/`). Ordered by package name (the render order); the completeness - * guard proves it is exhaustive against the on-disk glob. + * The boot manifest: every shipped model-facing tool package (a `tool-*` leaf + * under `packages/`, excluding {@link NON_MODEL_TOOL_PACKAGES}). Ordered by + * package name (the render order); the completeness guard proves it is + * exhaustive against the filtered on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ { @@ -256,8 +261,9 @@ interface CatalogPackage { export type ToolCatalog = CatalogPackage[] /** - * Assert the boot manifest covers every shipped tool package on disk (a - * `tool-*` leaf under `packages/`). + * Assert the boot manifest covers every shipped model-facing tool package on + * disk (a `tool-*` leaf under `packages/`, excluding explicit service-only + * entries in {@link NON_MODEL_TOOL_PACKAGES}). * Booting has no source declaration to enumerate, so this glob restores the * "a new tool cannot be silently undocumented" guarantee: an unlisted package * fails the generator (and the freshness gate) until it is added to @@ -266,7 +272,10 @@ export type ToolCatalog = CatalogPackage[] * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. */ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { - const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }) + .map(p => basename(p)) + .filter(dir => !NON_MODEL_TOOL_PACKAGES.has(dir)) + .sort() const listed = new Set(packages.map(p => p.dir)) const missing = onDisk.filter(dir => !listed.has(dir)) if (missing.length > 0) { @@ -339,9 +348,9 @@ export function render(catalog: ToolCatalog): string { '', 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', - 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator\'s boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', '## Tool Package Map', '', diff --git a/tsconfig.build.json b/tsconfig.build.json index 40d57030cb..a099fc3a23 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -38,6 +38,7 @@ { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/compact/tool-result-prune" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.json b/tsconfig.json index f39c097809..1decda2db0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,6 +60,7 @@ { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/compact/tool-result-prune" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, From 171bae5c20ee23abfafaf7a244e13ce1a2692649 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:28:31 +0800 Subject: [PATCH 014/132] fix(compact): harden pruning integration (round 2) --- docs/architecture.md | 4 +- docs/core-data-structures/compaction.md | 2 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 6 +- ...ction-pressure-and-overflow-recovery.zh.md | 6 +- packages/compact/compact-basic/README.md | 4 +- packages/compact/compact-basic/src/index.ts | 19 +++- .../compact-basic/tests/compact-basic.spec.ts | 45 +++++++++ packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 73 ++++++++++---- .../invariants/tests/invariants.spec.ts | 96 +++++++++++++++---- packages/ui/acp/README.md | 2 +- packages/ui/acp/acp-feature-support.md | 2 +- packages/ui/acp/src/index.ts | 7 +- packages/ui/acp/tests/load.spec.ts | 47 +++++++++ packages/ui/acp/tests/stream-update.spec.ts | 74 +++++++++++++- packages/ui/stdio/README.md | 2 +- packages/ui/stdio/src/index.ts | 4 + packages/ui/stdio/tests/stdio.spec.ts | 37 +++++++ 19 files changed, 372 insertions(+), 64 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d2289ff731..ff45d38389 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,11 +107,11 @@ Each step renders one prompt assembly. Plugins contribute ordered sections, tool Post-tool context follows all results, preserving call/result adjacency. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Leftover steering becomes next-turn input. `agent/turn-stop` is terminal through close and flush: later steering is discarded, while ordinary queued prompts survive. -When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`. The same turn signal owns both paths. +When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`, including when later summary work fails after a prune. The same turn signal owns both paths, and cancellation still wins. ### Failure Boundaries -The turn is the containment boundary. `LlmService` preserves and privately tags errors from final adapter selection, dispatch, and iteration. Those errors and terminal in-band error/aborted finishes close the failed step before `agent/request-error`; retry reconstructs the next numbered step from the log, while decline or failed recovery preserves the provider error. Attempts count consecutive failures and reset after success. +The turn is the containment boundary. `LlmService` preserves and privately tags errors from final adapter selection, dispatch, and iteration. Those errors and terminal in-band error/aborted finishes close the failed step before `agent/request-error`; retry reconstructs the next numbered step from the log, while decline or recovery failure before any replacement preserves the provider error. Attempts count consecutive failures and reset after success. Prompt, middleware, result, tool, post-step, and continuation failures remain ordinary `agent/error` failures. Cancellation and disposal beat recovery. Durable undispatched tool calls receive synthetic `ABORTED` results, preventing dangling replay. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 3a008a0b95..e3774b87cd 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. -Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index bd13a336f1..c8eef0f1c9 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 99dc7625b8e185464d7a8a1ea8eda5baf0674df7 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 4f84d4435341005c058e32582e6d26b2a9f29bc1 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: deedb81f8cf75ab80b70e2ef3148ba76d1278886 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 51d28fa243f522eedcac29002670575889d48369 diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 99dc7625b8..deedb81f8c 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -34,9 +34,9 @@ If cancellation lands after assistant tool calls are durable but before all call For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. -For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. +For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. -`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. +`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; cancellation or disposal remains authoritative even if recovery work completes concurrently. The default summarizer still resolves explicit configuration, then the latest logged route, then agent options. Because direct `llm/stream` middleware may reroute that auxiliary call, `compact/summary.model` records the final mutable `GenerateOptions.model` observed after dispatch rather than the pre-waterfall candidate. @@ -44,7 +44,7 @@ The default summarizer still resolves explicit configuration, then the latest lo Lifecycle tests pin post-step ordering after durable tool/context/steering work, content-less and max-token successes, final-adapter dispatch/iterator/in-band boundaries, retry numbering, attempt reset, cancellation, disposal, synthetic tool results, and original error identity. -Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. +Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, prune-then-summary-failure progress, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 4f84d44353..51d28fa243 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -34,9 +34,9 @@ Status: implemented 对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 -`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 +`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。 默认摘要器仍依次解析显式配置、最近记录的路由与 agent options。因为直接 `llm/stream` 中间件可以重新路由该辅助调用,`compact/summary.model` 记录分发后最终可变的 `GenerateOptions.model`,而不是 waterfall 之前的候选值。 @@ -44,7 +44,7 @@ Status: implemented 生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 -压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 +压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、剪枝后摘要失败的持久进展、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 ## 考虑过的替代方案 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 75be39aa1b..7acc1d0351 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -15,8 +15,8 @@ This backend owns the compaction policy: - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. -- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. -- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. `summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 91e8ad0cea..34567f7e07 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -100,15 +100,28 @@ export class BasicCompactService extends CompactService { || retryAttempt >= this.config.maxOverflowRetries || signal.aborted) return next() - let generation: number + const generation = agent.session.surface.replaceGeneration let result: CompactionResult | null try { - generation = agent.session.surface.replaceGeneration result = await this.compactIfNeeded(agent, 'context-overflow', signal) } catch (recoveryError: unknown) { const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError) + // A model-free prune can land before later summary work fails. That + // durable reduction is sufficient retry proof; do not discard it just + // because the optional second phase threw. Cancellation still wins. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + if (!signal.aborted && agent.session.surface.replaceGeneration > generation) { + ctx.logger.warn( + `context-overflow compaction failed after durable surface progress: ${message}; ` + + 'retrying from the replacement surface', + ) + return { action: 'retry' } + } ctx.logger.warn( - `context-overflow compaction failed: ${message}; preserving the original request error`, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. + `context-overflow compaction failed: ${message}; ${signal.aborted + ? 'cancellation prevents retry' + : 'preserving the original request error'}`, ) return next() } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 190fb026e7..094a22a11e 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1022,6 +1022,51 @@ describe('automatic listener and loader composition', () => { expect(compact.calls[0]!.text).toContain('tool result middle pruned') }) + it('retries from a durable prune when later overflow summarization throws', async () => { + const ctx = createContext(10_000) + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + void new ToolResultPruneService(ctx, { + thresholdChars: 100, + headChars: 20, + tailChars: 10, + }) + const compact = new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 900, + }) + compact.error = new Error('summary unavailable after prune') + const session = oversizedToolResult(3_000, true) + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + expect(session.surface.replaceGeneration).toBe(1) + expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) + expect(session.events.findLast(event => event.type === 'compact/end')?.data) + .toMatchObject({ error: 'summary unavailable after prune' }) + expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface')) + }) + + it('lets cancellation win when summary throws after a durable prune', async () => { + const ctx = createContext(10_000) + const controller = new AbortController() + void new ToolResultPruneService(ctx, { + thresholdChars: 100, + headChars: 20, + tailChars: 10, + }) + const compact = new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 900, + }) + compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') } + compact.error = new Error('summary cancelled after prune') + const session = oversizedToolResult(3_000, true) + + expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) + .toEqual({ action: 'fail' }) + expect(session.surface.replaceGeneration).toBe(1) + }) + it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => { const ctx = createContext() void new TestCompactService(ctx, { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 5ef2139db2..d80905e6ec 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,7 +31,7 @@ Session log (per session): - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. -- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call, while a provenance-backed single-node `replace` is a turn-enclosed surface rewrite of an already-executed result. A `tool/call` may still have no result when the execution pipeline throws. +- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A replacement exemption applies only to a provenance-backed rewrite of one current `tool/result` node whose complete data is identical except for `content`; it must still be turn-enclosed. A `tool/call` may still have no result when the execution pipeline throws. - **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance. Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f33f7b4cec..0e12bfa246 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -7,6 +7,7 @@ * @module @deepseek-ai/dsh-invariants */ +import { isDeepStrictEqual } from 'node:util' import type { Context } from 'cordis' import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' @@ -50,13 +51,14 @@ interface SessionTrace { pendingCalls: Set /** Every seq seen so far — validates `sourceEventSeqs` references. */ knownSeqs: Set - /** - * The seqs currently on the surface linked list, in linked-list order - * (head to tail). A replace reorders this relative to seq order (the new - * node takes the replaced range's position), so range validation is - * positional, not by seq comparison. - */ - surface: number[] + /** Current surface nodes in linked-list order, with immutable event identity. */ + surface: SurfaceTraceNode[] +} + +/** Immutable identity retained only while an event is on the current surface. */ +interface SurfaceTraceNode { + seq: number + event: SessionEvent } /** One accepted event's deferred mutation of a live session trace. */ @@ -70,8 +72,9 @@ interface SessionTraceTransition { | { kind: 'clear' } /** The event's mutation of the derived surface order. */ surface: - | { kind: 'none' | 'append' } - | { kind: 'replace'; start: number; count: number } + | { kind: 'none' } + | { kind: 'append'; node: SurfaceTraceNode } + | { kind: 'replace'; start: number; count: number; node: SurfaceTraceNode } /** The committed event sequence to add to the known-sequence set. */ seq: number } @@ -85,6 +88,18 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: } } +/** Compare future-safe tool-result data while deliberately excluding content. */ +function sameToolResultDataExceptContent( + original: SessionEvent<'tool/result'>['data'], + replacement: SessionEvent<'tool/result'>['data'], +): boolean { + const originalRest = { ...original } as Record + const replacementRest = { ...replacement } as Record + delete originalRest['content'] + delete replacementRest['content'] + return isDeepStrictEqual(originalRest, replacementRest) +} + /** Validate one candidate event without mutating the committed session trace. */ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition { // seq is strictly monotonic — the spine of replay equivalence. lastSeq @@ -139,14 +154,14 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr // positional range — every shadowed node must appear in sourceEventSeqs. if (se.surfaceOp !== undefined) { if (se.surfaceOp === 'append') { - surface = { kind: 'append' } + surface = { kind: 'append', node: { seq: event.seq, event: se } } } else { const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) + const startIdx = trace.surface.findIndex(node => node.seq === start) if (startIdx === -1) { throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) } - const endIdx = trace.surface.indexOf(end) + const endIdx = trace.surface.findIndex(node => node.seq === end) if (endIdx === -1) { throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) } @@ -155,13 +170,18 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr } // Every node the replace shadows (surface positions [startIdx, endIdx] // inclusive) must appear in sourceEventSeqs — the provenance contract. - const shadowed = trace.surface.slice(startIdx, endIdx + 1) + const shadowed = trace.surface.slice(startIdx, endIdx + 1).map(node => node.seq) const recorded = new Set(se.sourceEventSeqs ?? []) const missing = shadowed.filter(seq => !recorded.has(seq)) if (missing.length > 0) { throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } - surface = { kind: 'replace', start: startIdx, count: shadowed.length } + surface = { + kind: 'replace', + start: startIdx, + count: shadowed.length, + node: { seq: event.seq, event: se }, + } } } @@ -232,15 +252,26 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr break } case 'tool/result': { - // A replacement rewrites an already-executed result whose recorded - // turn/step can be closed. Surface provenance above validates the rewrite; - // only fresh appends consume an open step's pending call. + // Only a content-only rewrite of one CURRENT tool-result node may bypass + // open-step/pending-call checks. The trace retains immutable surface event + // identity, so this validation never indexes a mutable or stale session. if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') { if (trace.openTurn === null) { throw new InvariantError( 'tool/result surface replacement appended outside any open turn', ) } + const { start, end } = se.surfaceOp + if (start !== end) { + throw new InvariantError('tool/result surface replacement must rewrite exactly one current node') + } + const original = trace.surface.find(node => node.seq === start)?.event + if (original?.type !== 'tool/result') { + throw new InvariantError('tool/result surface replacement must target a current tool/result') + } + if (!sameToolResultDataExceptContent(original.data, event.data)) { + throw new InvariantError('tool/result surface replacement may change only content') + } break } requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) @@ -302,10 +333,14 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition case 'none': break case 'append': - trace.surface.push(transition.seq) + trace.surface.push(transition.surface.node) break case 'replace': - trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq) + trace.surface.splice( + transition.surface.start, + transition.surface.count, + transition.surface.node, + ) break /* v8 ignore next -- validateEvent produces this closed transition union */ default: diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 986cd93e7c..c55f3f1147 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -478,6 +478,39 @@ describe('HMR safety', () => { }) describe('surface invariants', () => { + async function toolResultRewriteFixture() { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const unrelated = session.append('user/message', { + content: [{ type: 'text', text: 'request' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('tool/call', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + name: 'echo', + arguments: '{}', + }) + const originalData = { + turn: 1, + step: 1, + callId: CallId('rewrite'), + content: [{ type: 'text' as const, text: 'original' }], + isError: true, + error: { name: 'ExitError', code: 'EXIT_1' }, + meta: { presentation: { kind: 'terminal', output: 'full output' } }, + futureField: { nested: ['preserve', 1] }, + } + const original = session.append('tool/result', originalData, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + return { session, unrelated, original } + } + it('accepts well-formed surface metadata', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -501,27 +534,7 @@ describe('surface invariants', () => { }) it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { - turn: 1, - step: 1, - callId: CallId('rewrite'), - name: 'echo', - arguments: '{}', - }) - const original = session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('rewrite'), - content: [{ type: 'text', text: 'original' }], - isError: false, - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const { session, original } = await toolResultRewriteFixture() expect(() => session.append('tool/result', { ...original.data, @@ -532,6 +545,47 @@ describe('surface invariants', () => { })).not.toThrow() }) + it('rejects a tool-result replacement targeting an unrelated current node', async () => { + const { session, unrelated, original } = await toolResultRewriteFixture() + expect(() => session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: 'forged' }], + }, { + surfaceOp: { op: 'replace', start: unrelated.seq, end: unrelated.seq }, + sourceEventSeqs: [unrelated.seq], + })).toThrow(/must target a current tool\/result/) + }) + + it('rejects a multi-node tool-result replacement even with complete provenance', async () => { + const { session, unrelated, original } = await toolResultRewriteFixture() + expect(() => session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: 'forged' }], + }, { + surfaceOp: { op: 'replace', start: unrelated.seq, end: original.seq }, + sourceEventSeqs: [unrelated.seq, original.seq], + })).toThrow(/must rewrite exactly one current node/) + }) + + it.each([ + ['callId', { callId: CallId('forged') }], + ['turn', { turn: 2 }], + ['step', { step: 2 }], + ['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }], + ['meta', { meta: { presentation: { kind: 'generic' } } }], + ['future data', { futureField: { nested: ['changed'] } }], + ])('rejects a content rewrite with altered %s', async (_label, altered) => { + const { session, original } = await toolResultRewriteFixture() + expect(() => session.append('tool/result', { + ...original.data, + ...altered, + content: [{ type: 'text', text: 'pruned' }], + }, { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/may change only content/) + }) + it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { const { ctx } = await setup() const session = ctx.sessions.create() diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3650cf27d4..15733cb9d9 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -99,7 +99,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa **What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. -**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. +**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten. ### Permission preset switches diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 29b0abc073..f1c3a918d5 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -82,7 +82,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. | | `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. | | `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | -| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. | +| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. | | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | | `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c464ffd22b..66719d0ddd 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -904,7 +904,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * loaded transcript reconstructs the USER side of each turn without echoing * a live `session/prompt` back to the client * - `tool/call` → `tool_call` (pending) - * - `tool/result` → `tool_call_update` (completed/failed) + * - appended `tool/result` → `tool_call_update` (completed/failed) + * - replacement `tool/result` → no update (context rewrite, not execution) * * Tool-call presentation (title/kind/rawInput, and the completed-state content) * is owned by each TOOL via `presentCall`/`presentResult` — the bridge never @@ -965,6 +966,10 @@ export function streamSessionEventUpdate( return } case 'tool/result': { + // Replacements (for example model-free pruning) are transcript rewrites, + // not repeated tool executions. Re-presenting one would consume no + // pending call and could clobber the original terminal/diff completion. + if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta) notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) }) return diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index f57767fb38..4c22681b79 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -162,6 +162,53 @@ describe('acp bridge — session/load replay', () => { expect(meta.terminal_exit?.exit_code).toBe(0) }) + it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => { + live = await makeBridgeHarness({ + storageDir, + withBash: true, + script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')], + }) + await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } }) + const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] }) + + const session = live.ctx.agents.get(AgentId(sessionId))!.session + const original = session.events.find(event => event.type === 'tool/result') + if (original?.type !== 'tool/result') throw new Error('expected original tool/result') + const liveCompletions = () => live!.updates.filter(update => + update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1') + expect(liveCompletions()).toHaveLength(1) + expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data) + .toBe('full\n') + + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], + }, { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + + // The replacement is durable but is not another live completion. + expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) + expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned') + expect(liveCompletions()).toHaveLength(1) + await live.dispose() + live = undefined + + loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] }) + await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } }) + await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + + const replayed = loader.updates.filter(update => + update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1') + expect(replayed).toHaveLength(1) + expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data) + .toBe('full\n') + }) + it('a load whose resume finishes after a client disconnect leaks no live session', async () => { // Stall persistence so transport closes while resume is pending. Whether the SDK rejects first // or the bridge's post-await guard fires, no agent may survive for the dead connection. diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 2afa49e1d4..c2884e0b1f 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -105,6 +105,22 @@ describe('streamSessionEventUpdate', () => { expect((failed[0] as { status: string }).status).toBe('failed') }) + it('emits no execution update for a tool-result surface replacement', () => { + const replacement = { + ...evt('tool/result', { + turn: 1, + step: 1, + callId: CallId('c1'), + content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], + isError: false, + }), + seq: 2, + surfaceOp: { op: 'replace', start: 1, end: 1 }, + sourceEventSeqs: [1], + } as SessionEvent + expect(updatesFor(replacement)).toEqual([]) + }) + it('drops non-text tool-result content (text-only)', () => { const update = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), @@ -450,6 +466,16 @@ describe('terminal-card mapping (capability-gated)', () => { const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) }) const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false }) + const prunedResultEvent = { + ...resultEvent, + seq: 2, + data: { + ...resultEvent.data, + content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], + }, + surfaceOp: { op: 'replace', start: 1, end: 1 }, + sourceEventSeqs: [1], + } as SessionEvent function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] { const presenter = new ToolPresenter(registryOf(tool)) @@ -477,6 +503,27 @@ describe('terminal-card mapping (capability-gated)', () => { }) }) + it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => { + const updates = termUpdates( + termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), + true, + '/work/proj', + callEvent, + resultEvent, + prunedResultEvent, + ) + expect(updates).toHaveLength(2) + expect(updates[1]).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + _meta: { + terminal_output: { terminal_id: 'c1', data: 'hi\n' }, + terminal_exit: { terminal_id: 'c1', exit_code: 0 }, + }, + }) + }) + it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => { const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs') @@ -633,17 +680,38 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo // call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`, // which presentResult narrows into a `diff` result card the bridge forwards as `{ type: // 'diff' }` content blocks. The real tool is required because its result metadata is the contract. - it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => { + it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => { const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) // The applied hunk the tool would compute and persist on the result meta. const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } - const [, resultUpdate] = updatesWith( + const originalResult = evt('tool/result', { + turn: 1, + step: 1, + callId: CallId('e1'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + meta, + }) + const replacement = { + ...originalResult, + seq: 3, + data: { + ...originalResult.data, + content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], + }, + surfaceOp: { op: 'replace', start: 2, end: 2 }, + sourceEventSeqs: [2], + } as SessionEvent + const updates = updatesWith( presenter, evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), - evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), + originalResult, + replacement, ) + expect(updates).toHaveLength(2) + const resultUpdate = updates[1] expect(resultUpdate).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'e1', diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..2327857b82 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -27,7 +27,7 @@ The plugin seeds display labels from the live agent registry, then tracks `agent **What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. -**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. +**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line. ### Terminal user-interaction answers diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..aea10827c7 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -124,6 +124,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt inReasoning = false output.write(`\n [tool call] ${toolName}(${args})`) } else if (event.type === 'tool/result') { + // A surface replacement changes future model context; it is not another + // execution. Keep the original full-fidelity terminal presentation and + // suppress duplicate output during live delivery or log replay. + if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return const { content } = event.data const text = content.filter(block => block.type === 'text').map(block => block.text).join('') output.write(`\n [tool result] ${text}\n `) diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index 7bb6a6f245..f757f62401 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -288,6 +288,43 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[tool result] file.txt') }) + it('renders one full-fidelity result whether the event feed is live or replayed', async () => { + const { ctx, out } = await setup() + const session = makeSession('main') + const original = { + type: 'tool/result', + seq: 2, + time: 0, + data: { + turn: 1, + step: 1, + callId: 'c1', + content: [{ type: 'text', text: 'full terminal output' }], + isError: false, + meta: { terminal: { output: 'full terminal output' } }, + }, + surfaceOp: 'append', + } as SessionEvent + const replacement = { + ...original, + seq: 3, + data: { + ...original.data, + content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], + }, + surfaceOp: { op: 'replace', start: 2, end: 2 }, + sourceEventSeqs: [2], + } as SessionEvent + + // Stdio consumes the same session/event shape whether a host forwards a + // live append or replays a stored log through the rendering feed. + for (const event of [original, replacement]) ctx.emit('session/event', session, event) + + expect(out.text().match(/\[tool result\]/g)).toHaveLength(1) + expect(out.text()).toContain('full terminal output') + expect(out.text()).not.toContain('tool result middle pruned') + }) + it('renders a todo/write session event as a glyphed checklist', async () => { const { ctx, out } = await setup() const session = {} as Session From d7ead6fdec930362de7877d33a991f5ba0d31aeb Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 16 Jul 2026 18:47:46 +0800 Subject: [PATCH 015/132] docs(rfc): correct harness loop contracts --- .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 184 ++++++++++-------- .../2026-07-16-harness-level-loop.zh.md | 184 ++++++++++-------- 3 files changed, 208 insertions(+), 164 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml index fe26d5d8a4..b167a54cef 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 8bc4ecf3734a7e9d82d1f844b3d7186fa6ccafed -2026-07-16-harness-level-loop.zh.md: 4e1b677259b310171098cc78dd174b55c8d69970 +2026-07-16-harness-level-loop.md: d308442c53dacd4fdb0d298c21e843899aba8d5c +2026-07-16-harness-level-loop.zh.md: b4884f2082deb601a1a5308911cb9458fe72f8af diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md index 8bc4ecf373..d308442c53 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.md @@ -13,7 +13,7 @@ The existing code offers three "just enough to run" alternatives, none of them a | Alternative | Problem | |---|---| | A `packages/workflow` script expressing `while (!done)` | The README explicitly writes "No token-budget vocabulary" and "No journaling or resume"; the parent turn blocks until the script settles. Fine for orchestration lasting minutes, unusable for tasks lasting hours | -| An external shell `while :; do dsh …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery | +| An external shell `while :; do dsh-sdk …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery | | The `sendMessage`/`resume` capabilities on the `packages/subagent` seam | The README explicitly writes "Runtime steering and continuation are seam-only capabilities". There is no model-facing consumer, so the model can only start a fresh subagent | Three typical use cases. **Automated fix**: a failing test suite in front of you, and you want a process to keep modifying code, running tests, and modifying again against the failure messages, until everything is green or the budget cap is hit. **Rubric-driven iterative revision**: a document, code, or translation must meet a set of scoring criteria; the loop repeatedly adjusts, an independent evaluator scores, and the loop stops when the criteria are met or the round budget is exhausted. **Unattended long runs**: for example, porting a repository from one tech stack to another overnight, kicked off before leaving work and reviewed the next morning, with the budget as the only safety net. Common shape across all three: minutes to hours, evaluator decides success, budget is a hard constraint, and post-run review and recovery are required. @@ -33,16 +33,16 @@ This RFC only **adds a capability seam `packages/loop/`** for the goal-based sha Three packages: -- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, four built-in service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`), and the event schema +- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, the Phase 1 service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff`), and the event schema; `GoalReflector` joins in Phase 2 with its first caller - `@deepseek-ai/dsh-loop-driver`: the default driver implementation -- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh loop` +- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh-sdk loop` -The design is organized around four concrete problems, each addressed by an independent cordis service seam: +The design is organized around four concrete problems, addressed by service seams or explicit driver policies in the phase where each has a caller: 1. A long-running loop that goes wrong leaves no systematic diagnosis or recovery. **Loop as an independent session** addresses this. 2. Whether the PASS at loop end is trustworthy determines whether hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face. **Making Evaluator and Budget into service seams** addresses this. 3. Short and long tasks need opposite memory strategies; hardcoding one mode makes the other class of scenario unusable. **Making RoundHandoff into a service seam** addresses this. -4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Making GoalReflector into a service seam** addresses this. +4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Goal concern events and policies cover Phase 1; the GoalReflector service arrives with the Phase 2 `reflect` path**. Beyond the four seams, one principle threads through the whole document: **one loop handles one atomic goal**. Large goals should be split into several small loops chained in sequence, not stuffed into one loop with the evaluator judging multiple things. A rule of thumb for whether granularity is right: if you cannot say what a finished loop actually accomplished, granularity is too large and should be split. Phase 2 adds a `loop_split` model-facing tool so the agent can split an oversized goal itself. @@ -55,7 +55,7 @@ interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; ev type StopCondition = | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } | { kind: 'approval-required'; reason: string } | { kind: 'user-cancel' } @@ -67,21 +67,21 @@ export {} Once a long-running loop goes wrong, the user has no systematic diagnostic method. A failure hours in leaves only scattered log files to sift through. Discovering that some middle round went off track and wanting to roll back to re-run means starting over from scratch. An agent wanting to consult its own experience from past loops has no API to reach it. -The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three capabilities. +The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three diagnostic and replay capabilities. -- **Resume from any round**: discover round 78 went off, restart from round 77 with a different prompt or evaluator, no need to start over -- **Post-hoc diagnosis**: through [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md), query "which round did the evaluator start hanging on the same criterion" to locate the stuck point -- **Meta-loop learning**: before starting a new loop, the agent queries its own experience from past loops of the same kind—"have I fixed a similar bug before? Which round did it fail on?" +- **Replay conversation from a recorded round**: while the source session is live, discover round 78 went off and fork the round-77 event prefix with a different prompt or evaluator; persisted replay needs a separate trusted load-and-seed path. Both forms replay conversation state against the current workspace, not the files and external side effects that existed at round 77 +- **Post-hoc diagnosis**: through the existing `ctx.sessionQuery` exact-read service, inspect the round where the evaluator started hanging on the same criterion +- **Meta-loop learning**: the proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) can later find related historical loops before a new run—"have I fixed a similar bug before? Which round did it fail on?" Claude Code's and Codex's `/goal` are one-off objects: discarded when the run ends, so the agent starts from zero when facing a similar problem again. -**Storage and dependency**. A few KB of events per round, roughly 100–500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10–100×, and the README calls this out clearly. **The most critical point**: this section's capabilities have a hard dependency on the not-yet-landed [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md). If that RFC does not land, arbitrary-round resume and query capability degrade to "just grep the JSONL files". If Phase 1 ships before that RFC merges, Phase 1 only guarantees correct event shape and defers the query surface to Phase 2. +**Storage and recovery boundary**. A few KB of events per round, roughly 100–500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10–100×, and the README calls this out clearly. Exact live and persisted reads already exist through `ctx.sessionQuery`; FTS5 is an optional discovery improvement, not a Phase 1 dependency. Exact execution-world restore is not promised: `SessionStore.fork()` accepts a live session, and session events do not restore files, processes, environment, or external side effects. Restoring those requires a separate Git/worktree/checkpoint design. ### Pluggable Evaluator and Budget A loop's value ultimately depends on whether the final PASS is trustworthy. If the evaluator can be hacked or hallucinates PASS, hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face: the model has the means to talk itself into PASS. Even letting an independent subagent be the evaluator only mitigates the problem; as long as the evaluator is still an LLM, it retains a systematic bias for the same class of content—an independent subagent is a mitigation, not a cure. -Truly trustworthy evaluation must be a fully non-LLM hard check: shell exit code, static analysis, an external service. The LLM physically cannot touch the evaluation process. But only the user knows which hard check to run: `pytest` commands differ by project, companies have private compliance checkers, some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into. +Trustworthy evaluation needs both a deterministic judgment mechanism and an isolation boundary appropriate to the threat model: shell exit code, static analysis, or an external service avoids LLM self-judgment, while a separate worktree, read-only mount, container, or remote service prevents the worker from rewriting evaluator inputs. Only the user knows which checks and boundary to use: `pytest` commands differ by project, companies have private compliance checkers, and some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into. Budget is the same story: product-level spending guardrails are opaque, and cannot be adjusted for team policy (personal card, team splitting, per-PR settlement). @@ -91,47 +91,47 @@ Budget is the same story: product-level spending guardrails are opaque, and cann interface RubricItem { name: string; description: string } interface EvaluatorContract { readonly name: string } -type EvaluatorSpec = { - tier: - | { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0" - | { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence - | { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验) - | { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标 - /** - * 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。 - * 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。 - * 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。 - */ - protectedPaths?: readonly string[] +type CriteriaSpec = + | { kind: 'single-metric'; name: string } + | { kind: 'rubric'; criteria: RubricItem[] } + | { kind: 'contract'; interface: EvaluatorContract } + +type ExecutorSpec = + | { kind: 'shell'; command: string } + | { kind: 'llm-judge'; rubric: string; model: string } + | { kind: 'provider'; name: string; config?: unknown } + +type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' + +interface EvaluatorSpec { + criteria: CriteriaSpec + executor: ExecutorSpec + isolation: IsolationSpec } export {} ``` -**Why tiers instead of letting the user pass any function?** Tiers force the user, at start time, to declare "which strength of judgment I'm using". A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing strict judgment when they've actually written a fuzzy LLM-judge-style semantic match. In long-run scenarios the cost is hours wasted. +**Why explicit dimensions instead of letting the user pass any function?** The spec forces the user, at start time, to declare what is judged, what executes the judgment, and what isolation boundary protects it. A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing a deterministic isolated check when they've actually written a same-workspace LLM judgment. In long-run scenarios the cost is hours wasted. -The four tiers descend in trustworthiness from top to bottom. The top three are fully non-LLM hard checks. `llm-judge` is the weakest tier, only for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this tier cannot defend against targeted adversarial input; long unattended runs require at least one hard-check tier alongside". +Criteria shape, executor, and isolation are orthogonal rather than a trust ladder: a rubric may be checked by shell, an LLM, or an external service, and a contract may run in the same workspace or in a container. `llm-judge` remains the weakest executor for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this executor cannot defend against targeted adversarial input; long unattended runs require at least one deterministic evaluator with an isolation boundary appropriate to the threat model". -The driver enforces five hard constraints, not delegated to Evaluator implementations. They address three distinct attack surfaces: +The driver enforces four structural constraints, not delegated to Evaluator implementations. Isolation strength remains an explicit property of the configured provider rather than a claim the driver can manufacture. **Preventing "the same agent both generates and self-evaluates"**: -1. **fresh subagent**: the evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context - -**Preventing the main agent from reward-hacking by modifying evaluator inputs**: - -2. **`protectedPaths` hard isolation**: paths declared in `EvaluatorSpec` (typically test files and evaluator config) are rejected for main-agent writes by the existing policy gate in `packages/fs`, and blocked writes are recorded as `loop/hack-attempt` session events. This is Phase 1's real defense against reward hacking—it seals off the "modify the tests so the evaluator passes" path directly +1. **fresh subagent for LLM evaluation**: an LLM evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context **Preventing the evaluator subagent itself from being subverted**: -3. **read-only tool set**: the evaluator's tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). It defends not against main-agent reward hacking, but against the evaluator being prompted-injected via code embedded in the code being evaluated—so it cannot be tricked into modifying state +2. **scoped tool set**: an LLM evaluator's model-facing tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). This reduces accidental mutation but is not process isolation: shell, code runtimes, or another capability can still write unless the configured isolation boundary prevents it **Preventing the evaluator report itself from deceiving the driver**: -4. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly -5. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence +3. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly +4. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence -Together, the five decide that evaluator conclusions can only be driven by evidence—not by confidence, and not by the main agent quietly modifying tests. +Together, the four ensure that evaluator conclusions are structurally evidence-driven rather than confidence-driven. They do not stop the main agent from modifying evaluator inputs in a shared workspace. **Phase 1 ships three backends**: @@ -139,9 +139,9 @@ Together, the five decide that evaluator conclusions can only be driven by evide - `loop-evaluator-rubric-judge` implements `llm-judge`: a prewritten rubric plus LLM scoring, soft goals only - `loop-budget-preflight`: before each round starts, estimate `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`; refuse to start if the estimate exceeds `perRoundUsd`. The estimation model comes from MartinLoop `policy.ts:551-596` -A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). The `rubric` and `contract` tiers get built-in implementations in Phase 2; Phase 1 only exposes the types so third-party plugins can integrate first. +A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). A resolved budget carries `maxRounds`, optional `maxTokens` and `maxUsd`, and optional `perRoundUsd`. The driver checks per-round admission before starting work, then accumulates worker, evaluator, compaction, and reflector usage after every request. A token or USD cap may overrun by one in-flight request because usage arrives after completion; the `budget-cap` result reports `observed` and `maximum`. The `rubric` and `contract` criteria shapes get built-in executors in Phase 2; Phase 1 exposes the shapes so third-party plugins can integrate first. -**Limitation**: the "read-only tools" the evaluator subagent receives are still shell and fs reads within the same process, and could theoretically be bypassed by prompt injection. Defending against targeted adversarial input requires the two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes), which is a Phase 3 item. See Risks. +**Limitation**: `same-workspace` plus a read-only model-facing tool set is not hard isolation. The current `packages/fs` policy enforces read-before-edit and version guards, not path denial, and bash or code runtimes can bypass filesystem tools. Defending against targeted adversarial input requires a boundary across every mutation channel—such as a read-only mount, isolated worktree, container, or remote evaluator. The two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes) remains a Phase 3 item. See Risks. ### Pluggable RoundHandoff @@ -150,23 +150,40 @@ How context passes between rounds is a dilemma. Preserving the full prior conver Made a service seam: ```ts -interface RoundContext { loopId: string; round: number } -interface NextRoundSpec { mode: 'fresh' | 'continue' } +interface ContinuationRun { + readonly id: string + resume?(prompt: string): Promise +} + +interface PreviousRound { + result: unknown + evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + tokenUsage: number + summary: string + sessionId: string + run?: ContinuationRun +} + +interface RoundContext { loopId: string; round: number; previous: PreviousRound } + +type NextRoundSpec = + | { mode: 'fresh'; prompt: string } + | { mode: 'continue'; run: ContinuationRun; prompt: string } interface RoundHandoff { - buildNextRound(prev: RoundContext): NextRoundSpec + buildNextRound(prev: RoundContext, signal: AbortSignal): Promise } export {} ``` -Phase 1 ships three backends: +Phase 1 ships the fresh backend; Phase 2 adds the two continuation backends after provider continuation exists: -| Backend | Scenario | Mechanism | -|---|---|---| -| `handoff-fresh-with-summary` (default) | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append | -| `handoff-continue-with-compaction` (recommended middle) | Medium length, 5–20 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point | -| `handoff-continue-raw` (advanced) | ≤5 rounds, short tasks, testing | Plain continuation without truncation | +| Backend | Phase | Scenario | Mechanism | +|---|---|---|---| +| `handoff-fresh-with-summary` (default) | Phase 1 | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append | +| `handoff-continue-with-compaction` (recommended middle) | Phase 2 | Medium length, 5–20 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point | +| `handoff-continue-raw` (advanced) | Phase 2 | ≤5 rounds, short tasks, testing | Plain continuation without truncation | **Why default to fresh?** Every long-run loop that actually succeeded (repomirror, Kimi ralph-loop, autoresearch) uses fresh. Placing important loop state outside the context window under driver management is the correct posture for long runs. `handoff-continue-raw` violates this experience, and the README explicitly notes it is not suitable for long runs. @@ -174,13 +191,13 @@ Phase 1 ships three backends: **Why a seam rather than a three-choice flag?** Users can write 20-line plugins expressing hybrid strategies like "continue for the first 5 rounds, then fresh", or "auto-compact once when context hits 50%", without waiting for main-library support. -**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem. +**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh-sdk loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem. ### Pluggable GoalReflector The goal the user gives at loop start is not always accurate. It may be based on a wrong assumption (asking the agent to implement a feature with a since-deprecated API), it may not be clear enough (the agent discovers a clarification is needed only mid-work), or it may be invalidated by later information. Current loop-execution frameworks treat the goal as a contract frozen at start; the agent can only push down the original path, and the result is exhausting the budget on the wrong direction. -Made a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal". +Phase 2 makes this a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal". Phase 1 carries concern events plus the `stop` and `notify-continue` driver policies without registering an unused `GoalReflector` service. ```ts interface RoundContext { loopId: string; round: number } @@ -198,7 +215,7 @@ type GoalReflection = export {} ``` -**Concerns have three sources**, and Phase 1 ships the first two: +**Concerns have three sources**. Phase 1 ships the first two; the `GoalReflector` service and periodic source arrive together in Phase 2: - **Agent-initiated**: via the model-facing tool `loop_flag_concern({ concern, severity })`. An agent that realizes during investigation that "the library the user assumed has been deprecated" can raise directly - **Driver heuristic**: when budget passes 50% and zero criteria have passed, the driver auto-raises a `no-progress-toward-goal` concern @@ -207,58 +224,61 @@ export {} **Response strategy is controlled by the `onGoalConcern` config**. The four settings correspond to different philosophies about loop use; users choose by their team's collaboration style, and the driver takes no default stance: - `'stop'` (Phase 1 default): any concern triggers `StopCondition: approval-required`, and a human decides. A loop should never proceed on its own in the face of uncertainty—suitable for cautious teams and for high-impact loop scenarios -- `'notify-continue'` (Phase 1): record a high-priority `loop/goal-concern` session event plus an explicit ACP notification, then continue; a human reviews at the end. The loop internal is not interrupted—suitable for unattended long runs +- `'notify-continue'` (Phase 1): record an ordinary `loop/goal-concern` session event, then continue; a human reviews at the end. ACP has no general high-priority marker, so dedicated concern rendering is deferred with the ACP command infrastructure. The loop internal is not interrupted—suitable for unattended long runs - `'reflect'` (Phase 2): call `GoalReflector` to decide continue, revise, or stop. Delegates the initial judgment to an independent agent in place of a human—suitable for teams with moderate autonomy - Not registering a `GoalReflector` and leaving `onGoalConcern` unset = the most hands-off tier: the loop stops only on traditional stop conditions **Why default to `stop`?** In unattended scenarios, stopping one extra time is safer than running for hours in the wrong direction. Users who explicitly want unattended can switch to `notify-continue`. -A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: on resume, one can pick up from the round where the concern surfaced, swap the goal, and re-run—the work of the previous N rounds is not lost. +A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: a later replay can seed a new conversation from the round where the concern surfaced and swap the goal. This does not roll the workspace back to that round. -**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and resume can select any historical goal version. +**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and later replay can select any historical goal version without claiming workspace restoration. ### User surface Four trigger surfaces share one driver: -- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` starts a nested harness loop. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery -- **CLI**: `dsh loop --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage +- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` registers `kind: 'loop'` through `ctx.tasks`, returns the task id immediately, and runs the harness loop in the background. `task_output`, `task_list`, and `task_kill` provide collection and cancellation. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery +- **CLI**: `dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage - **cordis leaf**: declare a resident loop as a leaf in `cordis.yml`, with future `dsh-schedule` RFC integration for periodic triggering -- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session +- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh-sdk loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session The ACP slash command depends on: `packages/ui/acp`'s `available_commands_update` surface is currently unbuilt ([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md)). Once the harness's slash-command infrastructure lands, `/loop` and `/loop-flag-concern` only need to be registered against that infrastructure; the driver and tool interfaces do not change. This RFC reserves the names and specifies the argument shape, but does not commit the infrastructure itself—that belongs to a separate ACP catch-up RFC. -The default system prompt carries two hard constraints, distributed with every built-in `loop` tool: +The default system prompt carries two behavioral instructions, distributed with every built-in `loop` tool: 1. No writing of `TODO`, `FAKE`, or `PLACEHOLDER` placeholders to superficially pass the evaluator 2. No writing of empty `try/except` or `catch(_)` blocks so the evaluator ignores errors -Neither can be stopped at the seam layer; both are prompt-layer conventions. Users may customize the system prompt but the built-in constraints remain. +Neither can be enforced at the seam layer; both are prompt-layer guidance and must not be described as hard constraints. Users may customize the system prompt; evaluators that require these rules must check them explicitly. ### Relationship with existing code Direct reuse without modification: -- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; the evaluator gets the read-only tool set +- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; an LLM evaluator gets a scoped model-facing tool set, not a process-isolation guarantee +- `packages/tasks`—the model-facing loop is a `loop` task producer and reuses owner isolation, `task_output`/`task_list`/`task_kill`, completion notices, cancellation, and awaited cleanup - The SQLite backend from `packages/session-persistence`—the loop-session persists +- `packages/session-query`—exact live and persisted session reads for post-hoc diagnosis - `packages/compact`—the implementation basis for `handoff-continue-with-compaction` - `packages/todo`—an optional progress representation in single-session continue mode - If [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) lands first, the loop tool can use it for per-round UI updates Not touched: `packages/core/agent-loop` (the inner-loop semantics stay the same); `packages/workflow` (DAG orchestration vs. iterating one goal is an orthogonal relationship; the two READMEs cross-link in their "Related" section to describe the boundary). -Two dependencies not yet landed: +One dependency is not yet landed: -- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)—see the limitation paragraph of Loop as an independent session for the mitigation - The ACP slash-command infrastructure (the `available_commands_update` surface)—see User surface. Before the infrastructure lands, the slash-command trigger is absent while the other three trigger surfaces work as normal -The one modification to existing code can be deferred to Phase 2: adding a "resume an existing subagent" argument surface to `packages/subagent-tool`, used by the `handoff-continue-*` backends. The underlying `SubagentRun.sendMessage` and `resume` already exist as seam capabilities; only the tool-layer argument entrypoint is missing. If Phase 1 ships only `handoff-fresh-with-summary`, subagent-tool need not be touched at all; Phase 2 adds it. +The proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) is an optional Phase 2 discovery improvement over the existing exact-read query service, not a dependency for Phase 1 event access. + +Continuation work can be deferred to Phase 2: the `SubagentRun.sendMessage` and `resume` methods exist as optional seam capabilities, but the current `subagent-spawn` provider deliberately exposes neither. The two `handoff-continue-*` backends therefore require provider implementations, capability checks, ownership tests, and a consumer surface—not only a new argument on `packages/subagent-tool`. Phase 1 ships only `handoff-fresh-with-summary` and does not touch subagent continuation. ### Phasing -**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the four-tier `EvaluatorSpec` type plus the `protectedPaths` hard isolation (reusing the `packages/fs` policy gate), with built-in implementations for `single-metric` and `llm-judge` and the `rubric` and `contract` types open for integration; Default-FAIL enforcement; three built-in evaluator/budget/handoff backends; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; the default system prompt hard constraints. **Not included**: the session-query surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), the subagent-tool resume change, the stuck detector, the Reflector subagent, the `loop_split` tool, and the built-in implementations of the `rubric` and `contract` tiers. +**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the orthogonal criteria/executor/isolation `EvaluatorSpec`, with built-in implementations for shell and LLM-judge execution and rubric/contract criteria shapes open for integration; Default-FAIL enforcement; evaluator and cumulative-budget backends; `handoff-fresh-with-summary`; `ctx.tasks` integration; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; and the default system-prompt guidance. **Not included**: the SQLite FTS5 search surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), subagent continuation provider/tool work, the `GoalReflector` service, the stuck detector, the Reflector subagent, the `loop_split` tool, and built-in executors for every rubric/contract combination. -**Phase 2**: the query surface; the stuck detector (reproducing OpenHands's five patterns); the subagent-tool resume change (unlocking the two continue tiers of handoff); the Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; the built-in implementations of the `rubric` and `contract` tiers. +**Phase 2**: the SQLite FTS5 search surface; the stuck detector (reproducing OpenHands's five patterns); subagent continuation provider implementations, capability checks, and consumer surface (unlocking the two continue handoffs); the `GoalReflector` service and Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; and built-in executors for additional rubric/contract combinations. **Phase 3**: agent fleet (N parallel loops for the same goal, best result wins); integration with `dsh-schedule`; two-container evaluator isolation (evaluator definition files entirely inaccessible to the main agent, defending against reward hacking). @@ -274,13 +294,13 @@ The one modification to existing code can be deferred to Phase 2: adding a "resu **Skip the evaluator seam, ship a few built-ins**: lighter. Rejected—the core value of Pluggable Evaluator and Budget is that team-private evaluators can extend the system. Hardcoding leaves long unattended users no option but to modify the main library. -**Accept a free function that lacks an `EvaluatorSpec` tier**: allow users to pass any `(result) => boolean`. Rejected—the tier system forces users to declare at start time "which strength of judgment I'm using", the key to preventing quiet regression to a weaker tier. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios. +**Accept a free function that lacks an explicit `EvaluatorSpec`**: allow users to pass any `(result) => boolean`. Rejected—the criteria/executor/isolation dimensions force users to declare at start time what is judged, what runs the judgment, and what boundary protects it, preventing quiet regression to a weaker setup. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios. -**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` + `sqlite-session-query-provider` already provide equivalent capability; the payoff of a new engine is far smaller than the maintenance cost. +**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` plus the existing exact-read `ctx.sessionQuery` already cover Phase 1 diagnosis, while SQLite FTS5 can add search later; the payoff of a new engine is far smaller than the maintenance cost. **Fold goal reflection into the Evaluator seam** (have the evaluator return "criteria are impossible"): rejected—it conflates "was the goal achieved" with "is the goal still correct", which are orthogonal concerns. `Evaluator` should stay independent, read-only, and simple. -**Only add an event for goal-concern, no seam**: lighter. Rejected—the response-strategy family (stop / notify / reflect) is well defined and teams will want to plug in their own, so making it a seam pays off more than it costs. +**Only ever add an event for goal-concern, no seam**: lighter. Phase 1 does use the event plus `stop`/`notify` policies; rejected as the final design because the Phase 2 `reflect` path needs a replaceable response strategy. The seam lands with that first caller rather than ahead of it. **Ship the full Reflector subagent in Phase 1**: more complete. Rejected—`loop_flag_concern` tool plus no-progress heuristic plus the two-policy `onGoalConcern` already covers 80% of scenarios; running an independent subagent every round is expensive, and introducing it on demand in Phase 2 is more sensible. @@ -290,31 +310,33 @@ The one modification to existing code can be deferred to Phase 2: adding a "resu - The three packages `packages/loop/{loop,loop-driver,loop-tool}` are built as a capability seam; `dsh-loop` exports only types and registry - `StopCondition` discrimination covers all branches (unit); `assertNever` closes the switch at compile time -- The four services `Evaluator`, `BudgetPolicy`, `RoundHandoff`, and `GoalReflector` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly) -- `EvaluatorSpec`'s four-tier type converges at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately) +- The Phase 1 services `Evaluator`, `BudgetPolicy`, and `RoundHandoff` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly); no `GoalReflector` service is registered before the Phase 2 `reflect` consumer exists +- `EvaluatorSpec`'s criteria/executor/isolation dimensions converge at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately) - Default-FAIL fixture: when the evaluator report returns `{criterion, pass: true, evidence: []}`, the driver refuses that criterion flip and records an `evaluator/invalid-report` session event -- Each of the three built-in handoff backends has unit tests plus one e2e: `fresh-with-summary` (runs to pass), `continue-with-compaction` (runs past the token threshold to trigger compaction), `continue-raw` (runs 3 rounds) -- `dsh loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code -- Evaluator isolation fixture: the main agent has fs.write, the evaluator subagent's tool set does not; attempting to call fs.write is rejected by the registry -- protectedPaths fixture: with `EvaluatorSpec.protectedPaths: ["tests/**"]` declared, a main-agent attempt to write `tests/foo.py` is rejected by the `packages/fs` policy gate and recorded as a `loop/hack-attempt` session event, while the evaluator's read of that path succeeds -- Preflight guardrail fixture: inject a mock pricing table to construct a scenario over `perRoundUsd`; the driver refuses to start that round and emits a `budget-cap` StopCondition -- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields a `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues and the event carries an ACP high-priority marker; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) -- The default system prompt hard constraints (no TODO/FAKE/PLACEHOLDER, no empty catch) are distributed with the built-in `loop` tool, and a snapshot covers the prompt content -- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events; when Phase 2 adds the query surface, they are indexable by `loopId` +- `RoundHandoff` receives the previous result, evaluator report, token usage, summary, session id, optional run handle, and cancellation signal; Phase 1's `fresh-with-summary` has unit coverage plus one pass-path e2e, while continuation backend tests wait for Phase 2 provider support +- `dsh-sdk loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code +- Evaluator scoping fixture: the main agent has fs.write while an LLM evaluator's model-facing tool set does not; the result and documentation still label `same-workspace` as non-isolated, and no `protectedPaths` guarantee is exposed +- Budget fixtures cover `perRoundUsd` admission plus cumulative `maxRounds`, `maxTokens`, and `maxUsd` across worker and evaluator usage; an in-flight overrun emits `budget-cap` with `observed` and `maximum` +- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields an ordinary `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues without nonexistent ACP priority metadata; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) +- The default system-prompt guidance (no TODO/FAKE/PLACEHOLDER, no empty catch) is distributed with the built-in `loop` tool, and a snapshot covers the prompt content without treating it as enforcement +- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events and are readable through the existing exact-read `ctx.sessionQuery`; FTS5 search remains Phase 2 +- Model-facing loop startup returns a `loop` task id immediately; `task_output`, `task_list`, `task_kill`, parent-agent disposal, cancellation, producer reload, and service disposal cover owner isolation and awaited quiescence - The "Related" sections in `packages/loop/README.md` and `packages/workflow/README.md` cross-link and describe the "when to use workflow vs. when to use loop" boundary clearly - Unit 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene all green; the ACP rendering intent (`generic`) of the new tool has a snapshot ## Risks -**Dependency on [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) landing**. The user-visible value of Loop as an independent session (arbitrary-round resume plus meta-loop learning) requires it. The mitigation is in that section's limitation paragraph; Phase 1 does not hard-bind, and the query surface ships in Phase 2. +**Conversation replay is not workspace restore**. Exact session reads already exist, and FTS5 improves historical discovery rather than enabling correctness. Replaying a round prefix against the current workspace can diagnose or redirect a run, but reproducing the execution world at that round requires Git/worktree/checkpoint support and an explicit policy for external side effects. **The boundary between `packages/workflow` and loop is a recurring FAQ**. "Is multi-round a loop or a workflow?"—both READMEs must state clearly: workflow is "steps known, agent to run undecided, parallel or serial orchestration"; loop is "agent decided, round count undecided, evaluator decides when to stop". Unclear docs cause users to pick the wrong one. -**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. **Phase 1 blocks most cases via `protectedPaths`**: evaluator input files (tests, evaluator config) are declared write-forbidden for the main agent via the `packages/fs` policy gate, sealing off the "modify the tests to make the evaluator pass" path directly. It still cannot prevent the agent from learning the evaluator's pattern and evading it in substance (for example, writing code that satisfies the surface pattern but is semantically wrong). Users needing high adversarial strength need Phase 3's two-container approach: the entire evaluator runtime (binary, rubric, dependency libraries) sits in a container that the main agent cannot access, matching what Anthropic patch.py does. +**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. Phase 1's `same-workspace` mode does not prevent the agent from modifying tests or evaluator configuration through bash, code runtimes, or another write channel; the current `packages/fs` policy is not a path-isolation boundary. Users needing adversarial strength must choose an isolated worktree, read-only mount, container, or remote evaluator. Phase 3's two-container approach keeps the evaluator runtime (binary, rubric, dependency libraries) entirely inaccessible to the main agent, matching what Anthropic patch.py does. -**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. Mitigation goes through the two default system-prompt hard constraints in User surface; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator are safer. This class of problem cannot be cured at the seam layer. +**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. The two default system-prompt instructions in User surface are guidance only; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator get enforceable coverage. This class of problem cannot be cured at the seam layer. -**Budget estimation drift**. The pricing table is a constant; the estimate drifts once the model provider changes prices. A conservative approximation is not a bug in itself, but the README notes "actual billing is per usage events; preflight only defends against a single round exploding". +**Budget estimation drift and in-flight overrun**. Pricing can change, and cumulative token/USD usage becomes exact only after each worker, evaluator, compaction, or reflector request reports usage. Preflight protects a single round; cumulative caps stop the next request and may exceed the configured maximum by one in-flight request. The README reports both observed and maximum values and states that provider billing remains authoritative. + +**Background tasks are process-local**. `ctx.tasks` gives the model-facing loop owner isolation, generic collection/cancellation, completion notices, and awaited cleanup. Parent-agent or service disposal cancels and awaits the loop; a process crash cannot run cleanup, and durable restart remains outside Phase 1. **Long-run loop log growth**. A 100-round loop reaches MB scale for one session. `logDetail: 'summary'` is a safety net but Phase 1 defaults to `full`; Phase 2 adds summary semantics. diff --git a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md index 4e1b677259..b4884f2082 100644 --- a/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md +++ b/docs/rfc/proposed/feature/2026-07-16-harness-level-loop.zh.md @@ -13,7 +13,7 @@ Status: proposed | 替代 | 问题 | |---|---| | `packages/workflow` 脚本表达 `while (!done)` | README 明写「No token-budget vocabulary」和「No journaling or resume」;父 turn 阻塞到脚本 settle。能跑几分钟的编排,跑不了几小时的长期任务 | -| 外部 shell `while :; do dsh …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | +| 外部 shell `while :; do dsh-sdk …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | | `packages/subagent` seam 的 `sendMessage`/`resume` | README 明写「Runtime steering and continuation are seam-only capabilities」。没有 model-facing consumer,模型只能起 fresh 子会话 | 典型使用场景有三类。**自动化修复**:面前一个失败的测试套件,希望一个进程持续修改代码、跑测试、根据失败信息再修改,直到全绿或触达预算上限。**按 rubric 迭代改稿**:一份文档、代码或翻译需要满足打分标准,循环反复调整、独立评估者打分、直到达标或耗尽轮数。**无人值守长跑**:例如通宵把一个仓库从一种技术栈移植到另一种,下班前启动第二天回来看结果,全程只有预算兜底。三类共同的形态:几分钟到几小时、evaluator 决定成败、预算是硬约束、跑完还需要能回看和恢复。 @@ -33,16 +33,16 @@ Status: proposed 三个包: -- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、四个内置 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff` / `GoalReflector`)、事件 schema +- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、Phase 1 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff`)、事件 schema;`GoalReflector` 在 Phase 2 与首个调用方一起加入 - `@deepseek-ai/dsh-loop-driver`:默认 driver 实现 -- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh loop` +- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh-sdk loop` -设计围绕四个具体问题展开,每个问题对应一条独立的 cordis service seam: +设计围绕四个具体问题展开,在每项能力出现调用方的 phase 中通过 service seam 或显式 driver policy 解决: 1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。 2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。 3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。 -4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**GoalReflector 做成 service seam** 解决。 +4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**Phase 1 用 goal concern event 与 policy 处理;GoalReflector service 随 Phase 2 的 `reflect` 路径一起加入**。 四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。 @@ -55,7 +55,7 @@ interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; ev type StopCondition = | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds' } + | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } | { kind: 'approval-required'; reason: string } | { kind: 'user-cancel' } @@ -67,21 +67,21 @@ export {} 长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。 -Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种能力。 +Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种诊断与 replay 能力。 -- **从任意轮恢复**:发现第 78 轮偏航,从第 77 轮拉起,换 prompt 或换 evaluator 重跑,不必从头 -- **事后诊断**:通过 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 查「哪一轮 evaluator 开始一直挂在同条 criterion 上」定位卡点 -- **元循环学习**:agent 开新 loop 前查自己过往同类 loop 的经验——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 +- **从已记录轮次 replay 对话**:源 session 仍 live 时,发现第 78 轮偏航,可以 fork 第 77 轮的 event prefix,换 prompt 或 evaluator;已持久化 session 的 replay 还需要独立的受信任 load-and-seed 路径。两者都只会基于当前工作区 replay 对话状态,不会恢复第 77 轮的文件与外部副作用 +- **事后诊断**:通过现有 `ctx.sessionQuery` 精确读取 service 检查 evaluator 从哪一轮开始一直挂在同条 criterion 上 +- **元循环学习**:拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 后续可以在新 loop 启动前找到相关历史 loop——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。 -**存储与依赖**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。**最关键的一条**:本节能力硬依赖尚未落地的 [sqlite-session-query-provider RFC](2026-07-10-sqlite-session-query-provider.md)。若该 RFC 未落地,任意轮 resume 与查询能力会降级为「只能翻 JSONL 文件」。若 Phase 1 交付时该 RFC 还未 merge,本 Phase 只保证 event 结构正确,query 面延后到 Phase 2。 +**存储与恢复边界**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。通过 `ctx.sessionQuery` 的精确 live 与已持久化读取已经存在;FTS5 是可选的发现能力增强,不是 Phase 1 依赖。本 RFC 不承诺精确恢复执行世界:`SessionStore.fork()` 只接受 live session,而 session event 不会恢复文件、进程、环境或外部副作用。这需要单独的 Git/worktree/checkpoint 设计。 ### 可插拔的 Evaluator 与 Budget loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。 -真正可信的评估必须是完全非 LLM 的硬检查:shell exit code、静态分析、外部服务。LLM 物理上碰不到评估过程。但硬检查只有用户自己知道该跑什么:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 +可信评估同时需要确定性的判断机制,以及与 threat model 匹配的隔离边界:shell exit code、静态分析或外部服务避免 LLM 自评;独立 worktree、只读 mount、容器或远程服务防止 worker 改写 evaluator 输入。具体检查和边界只有用户知道:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。 @@ -91,47 +91,47 @@ loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator interface RubricItem { name: string; description: string } interface EvaluatorContract { readonly name: string } -type EvaluatorSpec = { - tier: - | { kind: 'single-metric'; check: string } // "pytest -q && ruff check"、"exit code == 0" - | { kind: 'rubric'; criteria: RubricItem[] } // 若干独立 criterion,各自 pass/fail + evidence - | { kind: 'contract'; interface: EvaluatorContract } // 结构化合约(如 API sig 校验) - | { kind: 'llm-judge'; rubric: string; model: string } // 兜底档,仅软目标 - /** - * 主 agent 不可写的路径(通常是 evaluator 会读的测试文件、评估配置)。 - * 违规写会被 packages/fs policy gate 拒绝,记 loop/hack-attempt session event。 - * 这是防 reward hacking 的核心机制——把「改测试让 evaluator 通过」这条路封死。 - */ - protectedPaths?: readonly string[] +type CriteriaSpec = + | { kind: 'single-metric'; name: string } + | { kind: 'rubric'; criteria: RubricItem[] } + | { kind: 'contract'; interface: EvaluatorContract } + +type ExecutorSpec = + | { kind: 'shell'; command: string } + | { kind: 'llm-judge'; rubric: string; model: string } + | { kind: 'provider'; name: string; config?: unknown } + +type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' + +interface EvaluatorSpec { + criteria: CriteriaSpec + executor: ExecutorSpec + isolation: IsolationSpec } export {} ``` -**为什么分档,而不是让用户传自由函数?** 档位强制用户在启动时明确「用哪一档强度判成败」。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做严格判定,实际写的是 LLM-judge 那种模糊的语义匹配。长跑场景下代价是几小时白跑。 +**为什么使用显式维度,而不是让用户传自由函数?** spec 强制用户在启动时声明评估什么、由什么执行判断,以及什么隔离边界保护它。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做确定性隔离检查,实际写的是同工作区 LLM 判断。长跑场景下代价是几小时白跑。 -四档从上到下可信度依次降低。前三档都是完全非 LLM 的硬检查。`llm-judge` 是最弱一档,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此档不能挡定向对抗,长跑无人值守场景需至少一档硬检查配合」。 +criteria shape、executor 与 isolation 是三个正交维度,不是可信度阶梯:rubric 可以由 shell、LLM 或外部服务检查,contract 也可以在同一工作区或容器中运行。`llm-judge` 仍是最弱的 executor,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此 executor 不能挡定向对抗,长跑无人值守场景至少需要一个确定性 evaluator,并配合与 threat model 匹配的隔离边界」。 -Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对付三类不同的攻击面: +Driver 强制四条结构约束,不下放给 Evaluator 实现。隔离强度仍是已配置提供方的显式属性,不是 driver 能凭空制造的保证。 **防「同一个 agent 既生成又自评」**: -1. **fresh subagent**:evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context - -**防主 agent 通过修改 evaluator 输入来 reward hack**: - -2. **`protectedPaths` 硬隔离**:`EvaluatorSpec` 声明的路径(通常是测试文件、评估配置)由 `packages/fs` 已有的 policy gate 拒绝主 agent 的写请求,记 `loop/hack-attempt` session event。这是 Phase 1 真正挡 reward hacking 的一层——直接封死「改测试让 evaluator 通过」这条路 +1. **LLM 评估使用 fresh subagent**:LLM evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context **防 evaluator subagent 自身被 subverted**: -3. **只读工具集**:evaluator 的 tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。它防的不是主 agent 的 reward hacking,而是 evaluator 读到被 evaluate 的代码里 embed 的 prompt injection 时不会被诱导去改状态 +2. **限制模型可见工具集**:LLM evaluator 的 model-facing tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。这会减少意外修改,但不是进程隔离:除非已配置隔离边界拦截,否则 shell、代码运行时或其他 capability 仍能写入 **防 evaluator 报告本身欺骗 driver**: -4. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 -5. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 +3. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 +4. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 -五条一起决定 evaluator 结论只能靠证据推动,无法靠自信推动,也无法靠主 agent 悄悄改测试推动。 +四条一起保证 evaluator 结论在结构上由证据推动,而不是由自信推动。它们不能阻止主 agent 在共享工作区中修改 evaluator 输入。 **Phase 1 内置三个 backend**: @@ -139,9 +139,9 @@ Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对 - `loop-evaluator-rubric-judge` 实现 `llm-judge`:预写 rubric + LLM 打分,仅软目标 - `loop-budget-preflight`:每轮启动前估 `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`,超 `perRoundUsd` 拒绝启动。估算模型来自 MartinLoop `policy.ts:551-596` -`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。`rubric` 与 `contract` 档 Phase 2 补内置实现,Phase 1 只暴露类型让第三方插件先接。 +`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。解析后的 budget 携带 `maxRounds`、可选 `maxTokens` 与 `maxUsd`,以及可选 `perRoundUsd`。driver 在启动工作前检查单轮准入,随后在每次请求后累计 worker、evaluator、compaction 和 reflector 用量。token 或 USD 上限可能被一个在途请求超出,因为 usage 在完成后才到达;`budget-cap` 结果同时报告 `observed` 与 `maximum`。`rubric` 与 `contract` criteria shape 在 Phase 2 补内置 executor,Phase 1 暴露这些 shape 让第三方插件先接。 -**局限**:evaluator subagent 拿到的"只读工具"仍是同一进程的 shell 与 fs 读,理论上仍可能被 prompt injection 绕过。挡定向对抗需要两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路),本 RFC Phase 3 才做。见 风险。 +**局限**:`same-workspace` 加只读 model-facing tool set 不是硬隔离。当前 `packages/fs` policy 实施 read-before-edit 与版本保护,不是路径拒写;bash 或代码运行时可以绕过 filesystem tool。挡定向对抗需要覆盖所有写入通道的边界,例如只读 mount、隔离 worktree、容器或远程 evaluator。两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路)仍在 Phase 3。见 风险。 ### 可插拔的 RoundHandoff @@ -150,23 +150,40 @@ Driver 强制五条硬约束,不下放给 Evaluator 实现。它们分别对 做成 service seam: ```ts -interface RoundContext { loopId: string; round: number } -interface NextRoundSpec { mode: 'fresh' | 'continue' } +interface ContinuationRun { + readonly id: string + resume?(prompt: string): Promise +} + +interface PreviousRound { + result: unknown + evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } + tokenUsage: number + summary: string + sessionId: string + run?: ContinuationRun +} + +interface RoundContext { loopId: string; round: number; previous: PreviousRound } + +type NextRoundSpec = + | { mode: 'fresh'; prompt: string } + | { mode: 'continue'; run: ContinuationRun; prompt: string } interface RoundHandoff { - buildNextRound(prev: RoundContext): NextRoundSpec + buildNextRound(prev: RoundContext, signal: AbortSignal): Promise } export {} ``` -Phase 1 内置三个 backend: +Phase 1 交付 fresh backend;Phase 2 在 provider continuation 存在后增加两个 continuation backend: -| Backend | 场景 | 机制 | -|---|---|---| -| `handoff-fresh-with-summary`(默认) | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | -| `handoff-continue-with-compaction`(推荐中间档) | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | -| `handoff-continue-raw`(专业档) | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | +| Backend | Phase | 场景 | 机制 | +|---|---|---|---| +| `handoff-fresh-with-summary`(默认) | Phase 1 | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | +| `handoff-continue-with-compaction`(推荐中间档) | Phase 2 | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | +| `handoff-continue-raw`(专业档) | Phase 2 | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | **为什么默认 fresh?** 所有实际跑成的长跑 loop(repomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。 @@ -174,13 +191,13 @@ Phase 1 内置三个 backend: **为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。 -**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 +**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh-sdk loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 ### 可插拔的 GoalReflector 用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。 -做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。 +Phase 2 把它做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。Phase 1 只携带 concern event,以及 `stop` 与 `notify-continue` driver policy,不注册没有调用方的 `GoalReflector` service。 ```ts interface RoundContext { loopId: string; round: number } @@ -198,7 +215,7 @@ type GoalReflection = export {} ``` -**concern 有三种触发来源**,Phase 1 实现前两种: +**concern 有三种触发来源**。Phase 1 实现前两种;`GoalReflector` service 与周期性来源一起在 Phase 2 加入: - **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise - **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern @@ -207,58 +224,61 @@ export {} **响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场: - `'stop'`(Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景 -- `'notify-continue'`(Phase 1):记 `loop/goal-concern` session event(高优先级)加 ACP 显式提示,继续跑,人在结束时集中审阅。loop 内部不打扰,适合无人值守长跑 +- `'notify-continue'`(Phase 1):记录普通 `loop/goal-concern` session event 后继续跑,人在结束时集中审阅。ACP 没有通用高优先级 marker,因此专用 concern 渲染与 ACP command 基础设施一起后置。loop 内部不打扰,适合无人值守长跑 - `'reflect'`(Phase 2):调 `GoalReflector` 决定 continue、revise 还是 stop。委派一个独立 agent 代替人做初步判断,适合中等自主度的团队 - 不注册 `GoalReflector` 且 `onGoalConcern` 未设 = 最放手档,loop 只在传统 stop condition 触发时停 **为什么默认选 `stop`?** 无人值守场景下宁可多停一次也不要在错方向上跑几小时。用户明确要无人值守可切 `notify-continue`。 -concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:resume 时可以从 concern 出现的那一轮拉起,换 goal 重跑,前 N 轮的工作不丢。 +concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:后续 replay 可以从 concern 出现的轮次为新对话提供 seed,并替换 goal。这不会把工作区回滚到该轮。 -**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,resume 时可选任意历史 goal 版本。 +**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,后续 replay 可选任意历史 goal 版本,但不承诺恢复工作区。 ### 用户面 四个触发面共享同一个 driver: -- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 启动嵌套 harness loop。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 -- **CLI**:`dsh loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 +- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 通过 `ctx.tasks` 注册 `kind: 'loop'`,立即返回 task id,并在后台运行 harness loop。`task_output`、`task_list` 和 `task_kill` 负责收集与取消。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 +- **CLI**:`dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 - **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 -- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 +- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh-sdk loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` 面目前是 unbuilt 状态([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md))。等 harness 的 slash command 基础设施落地,`/loop` 与 `/loop-flag-concern` 只需在该基础设施里注册;driver 与 tool 接口不变。本 RFC 保留名字并给出参数 shape,但不承诺基础设施本身——那属于独立的 ACP 补齐 RFC。 -默认 system prompt 里有两条硬约束,随所有内置 `loop` tool 一起分发: +默认 system prompt 里有两条行为指令,随所有内置 `loop` tool 一起分发: 1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过 2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误 -这两条不是 seam 层能拦的,是 prompt 层的约定。用户可以自定义 system prompt 但内置约束保留。 +这两条无法在 seam 层强制,只是 prompt 层 guidance,不能描述成硬约束。用户可以自定义 system prompt;需要强制这些规则的 evaluator 必须显式检查。 ### 与仓库现有代码的关系 直接复用无需修改: -- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent、evaluator 只读工具集 +- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent;LLM evaluator 获得受限的 model-facing tool set,不获得进程隔离保证 +- `packages/tasks`——model-facing loop 是 `loop` task producer,复用 owner isolation、`task_output`/`task_list`/`task_kill`、完成通知、取消和 awaited cleanup - `packages/session-persistence` 的 SQLite backend——loop-session 落盘 +- `packages/session-query`——精确读取 live 与已持久化 session,用于事后诊断 - `packages/compact`——`handoff-continue-with-compaction` 的实现基础 - `packages/todo`——单会话 continue 模式下作为可选 progress 表达 - 若 [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) 先落地,loop tool 可用它逐轮 UI 更新 不动:`packages/core/agent-loop`(inner loop 语义保持);`packages/workflow`(DAG 编排 vs. 迭代同 goal 是 orthogonal 关系,两个 README 在「Related」段互链说明边界)。 -依赖尚未落地的两处: +依赖尚未落地的一处: -- [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md)——见 Loop 作为独立 session 局限段的缓解方案 - ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作 -唯一涉及现有代码的改动可延后到 Phase 2:给 `packages/subagent-tool` 增加「续跑已有 subagent」的参数暴露,用于 `handoff-continue-*` 两个 backend。底层 `SubagentRun.sendMessage` 与 `resume` 已作为 seam 能力存在,缺的只是 tool 层的参数入口。若 Phase 1 只上 `handoff-fresh-with-summary`,完全不动 subagent-tool;Phase 2 再补。 +拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 是现有 exact-read query service 之上的可选 Phase 2 发现能力增强,不是 Phase 1 event 访问的依赖。 + +Continuation 工作可以延后到 Phase 2:`SubagentRun.sendMessage` 与 `resume` 方法作为可选 seam capability 存在,但当前 `subagent-spawn` provider 明确不暴露这两个方法。因此,两个 `handoff-continue-*` backend 需要 provider 实现、capability check、ownership 测试和 consumer surface,不只是给 `packages/subagent-tool` 增加参数。Phase 1 只交付 `handoff-fresh-with-summary`,不改 subagent continuation。 ### 分阶段 -**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;`EvaluatorSpec` 四档类型 + `protectedPaths` 硬隔离(复用 `packages/fs` policy gate),其中 `single-metric` 与 `llm-judge` 有内置实现,`rubric` 与 `contract` 类型开放待接;Default-FAIL 强制;3 个内置 evaluator/budget/handoff backend;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt 硬约束。**不含**:session-query 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent-tool 续跑改动、stuck 检测器、Reflector subagent、`loop_split` tool、`rubric` 与 `contract` 档的内置实现。 +**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;criteria/executor/isolation 三个正交维度的 `EvaluatorSpec`,其中 shell 与 LLM-judge execution 有内置实现,rubric/contract criteria shape 开放待接;Default-FAIL 强制;evaluator 与累计 budget backend;`handoff-fresh-with-summary`;`ctx.tasks` 集成;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt guidance。**不含**:SQLite FTS5 search 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent continuation provider/tool 工作、`GoalReflector` service、stuck 检测器、Reflector subagent、`loop_split` tool,以及每种 rubric/contract 组合的内置 executor。 -**Phase 2**:query 面;stuck 检测器(复现 OpenHands 5 种模式);subagent-tool 续跑改动(解锁 continue 两档 handoff);Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;`rubric` 与 `contract` 档的内置实现。 +**Phase 2**:SQLite FTS5 search 面;stuck 检测器(复现 OpenHands 5 种模式);subagent continuation provider 实现、capability check 与 consumer surface(解锁两个 continue handoff);`GoalReflector` service 与 Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;更多 rubric/contract 组合的内置 executor。 **Phase 3**:agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。 @@ -274,13 +294,13 @@ ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` **不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。 -**接受不带 `EvaluatorSpec` 档位的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——档位强制用户在启动时明确「用哪一档强度判成败」,是防止不知不觉滑到弱档的关键。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 +**接受不带显式 `EvaluatorSpec` 的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——criteria/executor/isolation 维度强制用户在启动时声明评估什么、由什么执行判断、由什么边界保护,防止不知不觉滑到更弱的配置。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 -**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` + `sqlite-session-query-provider` 已能提供等效能力;新引擎收益远小于维护成本。 +**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` 加现有 exact-read `ctx.sessionQuery` 已经覆盖 Phase 1 诊断,SQLite FTS5 后续可以补 search;新引擎收益远小于维护成本。 **goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。 -**goal-concern 只做 event 不做 seam**:更轻。拒绝——响应策略族(stop / notify / reflect)明确,各团队会想插自己的,seam 化投资小于收益。 +**goal-concern 永远只做 event 不做 seam**:更轻。Phase 1 确实使用 event 加 `stop`/`notify` policy;作为最终设计仍拒绝,因为 Phase 2 的 `reflect` 路径需要可替换响应策略。seam 与首个调用方一起落地,不提前出现。 **Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。 @@ -290,31 +310,33 @@ ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` - `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry - `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口 -- `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector` 四条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用) -- `EvaluatorSpec` 四档类型编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) +- Phase 1 的 `Evaluator`、`BudgetPolicy`、`RoundHandoff` 三条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用);Phase 2 `reflect` consumer 出现前不注册 `GoalReflector` service +- `EvaluatorSpec` 的 criteria/executor/isolation 维度在编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) - Default-FAIL fixture:evaluator 报告返回 `{criterion, pass: true, evidence: []}` 时 driver 拒绝该 criterion 翻转、记 `evaluator/invalid-report` session event -- 三个内置 handoff backend 都有单元 + 一个 e2e:`fresh-with-summary`(跑到 pass)、`continue-with-compaction`(跑超 token 阈值触发 compact)、`continue-raw`(跑 3 轮) -- `dsh loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 -- Evaluator 独立性 fixture:主 agent 有 fs.write,evaluator subagent 的 tool set 里没有;试图调 fs.write 被 registry 拒绝 -- protectedPaths fixture:`EvaluatorSpec.protectedPaths: ["tests/**"]` 声明后,主 agent 尝试写 `tests/foo.py` 被 `packages/fs` policy gate 拒绝并记 `loop/hack-attempt` session event,evaluator 侧读该路径正常 -- Preflight 护栏 fixture:注入 mock pricing 表构造超 `perRoundUsd` 的场景,driver 拒绝启动该轮且 emit `budget-cap` StopCondition -- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且事件带 ACP 高优先级标记;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) -- 默认 system prompt 硬约束(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容 -- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现;Phase 2 补 query 面时可按 `loopId` 检索 +- `RoundHandoff` 接收上一轮 result、evaluator report、token usage、summary、session id、可选 run handle 和 cancellation signal;Phase 1 的 `fresh-with-summary` 有单元覆盖与一个 pass-path e2e,continuation backend 测试等待 Phase 2 provider 支持 +- `dsh-sdk loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 +- Evaluator scope fixture:主 agent 有 fs.write,LLM evaluator 的 model-facing tool set 没有;结果与文档仍把 `same-workspace` 标记为未隔离,不暴露 `protectedPaths` 保证 +- Budget fixture 覆盖 `perRoundUsd` 准入,以及跨 worker 与 evaluator usage 累计的 `maxRounds`、`maxTokens`、`maxUsd`;在途超限 emit 带 `observed` 与 `maximum` 的 `budget-cap` +- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出普通 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且不携带不存在的 ACP priority metadata;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) +- 默认 system prompt guidance(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容但不把它当作强制机制 +- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现,并可通过现有 exact-read `ctx.sessionQuery` 读取;FTS5 search 留在 Phase 2 +- model-facing loop 启动后立即返回 `loop` task id;`task_output`、`task_list`、`task_kill`、父 agent dispose、取消、producer reload 和 service dispose 覆盖 owner isolation 与 awaited quiescence - `packages/loop/README.md` 和 `packages/workflow/README.md` 的「Related」段互链清楚「何时用 workflow、何时用 loop」的边界 - 单元 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene 全绿;新增 tool 的 ACP 渲染意图(`generic`)有 snapshot ## 风险 -**依赖 [sqlite-session-query-provider](2026-07-10-sqlite-session-query-provider.md) 落地**。Loop 作为独立 session 的用户可见价值(任意轮 resume + 元循环学习)需要它。缓解在该节局限段;Phase 1 不硬绑,Phase 2 才交付 query 面。 +**对话 replay 不是工作区恢复**。精确 session 读取已经存在,FTS5 改善历史发现能力,不决定正确性。基于当前工作区 replay 某一轮 prefix 可以诊断或改变运行方向,但复现该轮执行世界需要 Git/worktree/checkpoint 支持,以及针对外部副作用的显式 policy。 **`packages/workflow` 与 loop 的边界是持续答疑热点**。「多轮是 loop 还是 workflow」两个 README 必须写清楚:workflow 是「步骤已知、agent 未定、并串行编排」;loop 是「agent 已定、轮数未定、evaluator 判停」。文档不清晰会让用户混用错档。 -**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。**Phase 1 靠 `protectedPaths` 挡多数 case**:evaluator 的输入文件(测试、评估配置)通过 `packages/fs` policy gate 声明为主 agent 不可写,直接从「改测试让 evaluator 通过」这条路上封死。但仍无法阻止 agent 学出 evaluator 的模式做实质规避(比如写符合表面 pattern 但语义错的代码)。对抗强度高的用户需要 Phase 3 的两容器方案:evaluator 的整个运行时(二进制、rubric、依赖库)都在主 agent 完全不可访问的容器里,Anthropic patch.py 走的就是这条路。 +**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。Phase 1 的 `same-workspace` 模式不能阻止 agent 通过 bash、代码运行时或其他写入通道修改测试或 evaluator 配置;当前 `packages/fs` policy 不是路径隔离边界。需要对抗强度的用户必须选择隔离 worktree、只读 mount、容器或远程 evaluator。Phase 3 的两容器方案让 evaluator 整个运行时(二进制、rubric、依赖库)对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路。 -**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。缓解走 用户面 段那两条默认 system prompt 硬约束;用户自定义 evaluator 时若加入「静态检查禁止 TODO 与空 catch」这类规则更稳妥。这类问题不是 seam 层能根治的。 +**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。用户面 段的两条默认 system prompt 指令只是 guidance;用户在自定义 evaluator 中加入「静态检查禁止 TODO 与空 catch」才能获得可强制覆盖。这类问题不是 seam 层能根治的。 -**预算估算漂移**。pricing 表是常量,模型调价后估算会飘。护栏保守方向的近似不算 bug,但 README 说明「真实计费以 usage 事件为准,preflight 仅保护单轮爆炸」。 +**预算估算漂移与在途超限**。pricing 可能变化,累计 token/USD usage 只能在每次 worker、evaluator、compaction 或 reflector 请求报告 usage 后精确。preflight 保护单轮;累计上限会停止下一个请求,但可能被一个在途请求超出。README 同时报告 observed 与 maximum,并说明 provider 账单才是权威。 + +**后台 task 只存在于当前进程**。`ctx.tasks` 为 model-facing loop 提供 owner isolation、通用收集/取消、完成通知和 awaited cleanup。父 agent 或 service dispose 会取消并等待 loop;进程 crash 无法执行 cleanup,持久重启不在 Phase 1 范围内。 **长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`,Phase 2 再补 summary 语义。 From ba4a57c7d19c3119f1f3876a8472b758902ddc9d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:51:26 +0800 Subject: [PATCH 016/132] refactor(compact): align pruning contracts (round 3) --- docs/architecture.md | 2 +- docs/capability-seams.md | 6 ++-- docs/config-catalog.md | 32 +++++++++---------- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/compaction.md | 2 +- docs/module-graph.md | 12 +++---- .../2026-06-18-session-surface.md | 2 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +-- ...mpaction-pressure-and-overflow-recovery.md | 6 ++-- ...ction-pressure-and-overflow-recovery.zh.md | 6 ++-- .../2026-06-18-compaction-capability-seam.md | 14 ++++---- docs/tool-catalog.md | 4 +-- examples/coding-agent/composition.md | 4 +-- examples/coding-agent/cordis.yml | 2 +- examples/coding-agent/tests/harness.ts | 2 +- packages/compact/README.md | 4 +-- packages/compact/compact-basic/README.md | 10 +++--- packages/compact/compact-basic/package.json | 6 ++-- packages/compact/compact-basic/src/index.ts | 2 +- .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 ++-- packages/compact/compact-basic/tsconfig.json | 2 +- .../README.md | 4 +-- .../package.json | 2 +- .../src/config.ts | 0 .../src/index.ts | 2 +- .../src/types.ts | 0 .../tests/loader-composition.spec.ts | 10 +++--- .../tests/tool-result-prune.spec.ts | 4 +-- .../tsconfig.json | 0 packages/compact/compact/README.md | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 5 --- pnpm-lock.yaml | 10 +++--- python/sdk-runtime/package.json | 2 +- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 29 ++++++----------- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 38 files changed, 97 insertions(+), 111 deletions(-) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/README.md (96%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/package.json (94%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/config.ts (100%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/index.ts (99%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/types.ts (100%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tests/loader-composition.spec.ts (84%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tests/tool-result-prune.spec.ts (98%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tsconfig.json (100%) diff --git a/docs/architecture.md b/docs/architecture.md index ff45d38389..6c4fd39622 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction | -| `ctx.toolResultPrune` | [`compact/tool-result-prune`](../packages/compact/tool-result-prune/README.md) | optional model-free tool-result pruning | +| `ctx.toolResultPrune` | [`compact/compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | optional model-free tool-result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 816fc0ea3b..4ce3619025 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -16,7 +16,7 @@ flowchart LR pkg_compact_basic["compact-basic"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] - pkg_tool_result_prune["tool-result-prune"] + pkg_compact_tool_result_prune["compact-tool-result-prune"] svc_toolResultPrune["ctx.toolResultPrune
Model-free tool-result pruning"] pkg_session["session"] svc_sessions["ctx.sessions
In-memory session store"] @@ -104,6 +104,7 @@ flowchart LR pkg_code_runtime_worker --> svc_codeRuntime pkg_compact --> svc_compact pkg_compact_basic --> svc_compact + pkg_compact_tool_result_prune --> svc_toolResultPrune pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_llm --> svc_llm @@ -129,7 +130,6 @@ flowchart LR pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_result_prune --> svc_toolResultPrune pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -199,7 +199,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | -| `ctx.toolResultPrune` | `core` | [`tool-result-prune`](../packages/compact/tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | +| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb266a8119..cb338a9c75 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -245,6 +245,22 @@ export interface BasicCompactConfig { Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts) +## `@deepseek-ai/dsh-compact-tool-result-prune` + +```ts config-catalog +/** Character-budget policy for deterministic tool-result pruning. */ +export interface ToolResultPruneConfig { + /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */ + thresholdChars?: number + /** Maximum leading Unicode code points retained. Defaults to `4096`. */ + headChars?: number + /** Maximum trailing Unicode code points retained. Defaults to `1024`. */ + tailChars?: number +} +``` + +Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -930,22 +946,6 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) -## `@deepseek-ai/dsh-tool-result-prune` - -```ts config-catalog -/** Character-budget policy for deterministic tool-result pruning. */ -export interface ToolResultPruneConfig { - /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */ - thresholdChars?: number - /** Maximum leading Unicode code points retained. Defaults to `4096`. */ - headChars?: number - /** Maximum trailing Unicode code points retained. Defaults to `1024`. */ - tailChars?: number -} -``` - -Source: [`packages/compact/tool-result-prune/src/types.ts:4`](../packages/compact/tool-result-prune/src/types.ts) - ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e2fc41b943..2a5f37cacb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -284,7 +284,7 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](../core-data-structures/core.md) -Source: [`packages/compact/tool-result-prune/src/index.ts:39`](../../packages/compact/tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts:39`](../../packages/compact/compact-tool-result-prune/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index e3774b87cd..5981ed2d0a 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. -Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/module-graph.md b/docs/module-graph.md index fbe9afd59b..685bd810ce 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,7 +45,7 @@ flowchart TD subgraph group_compact["packages/compact"] pkg_compact["compact"] pkg_compact_basic["compact-basic"] - pkg_tool_result_prune["tool-result-prune"] + pkg_compact_tool_result_prune["compact-tool-result-prune"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -169,8 +169,8 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session - pkg_tool_result_prune --> pkg_llm - pkg_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web pkg_web_search_deepseek --> pkg_web @@ -185,10 +185,10 @@ flowchart TD pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter - pkg_compact_basic --> pkg_tool_result_prune pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -411,7 +411,7 @@ flowchart TD | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-result-prune`](../packages/compact/tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | @@ -420,7 +420,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter), [`tool-result-prune`](../packages/compact/tool-result-prune) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 3a8805ddb6..38f1085b81 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -66,4 +66,4 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`packages/session-persistence/session-persistence`**: Abstract interface unchanged. -The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes — the new node takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. +The surface is the foundation for future history manipulation. A compaction or compact-tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed nodes — the new node takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index c8eef0f1c9..6f6ca74dd2 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: deedb81f8cf75ab80b70e2ef3148ba76d1278886 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 51d28fa243f522eedcac29002670575889d48369 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: ea1e2b21c8036ab4d89c022792f252becc3826f6 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 594f396aaf891dccb9d6545e78039f0dbba51d1a diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index deedb81f8c..ea1e2b21c8 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -16,9 +16,9 @@ Successful calls are not the only pressure signal. A provider can reject a reque `agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields. -The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A listener failure is an ordinary turn failure; it never enters model-request recovery. +The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A propagated listener failure is an ordinary turn failure; it never enters model-request recovery. Compact-basic contains its expected operational failures as described below. -`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue with full history. +`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue from the latest durable surface: full history before any replacement, or the pruned surface if pruning already landed. ### Request recovery is limited to the final model boundary @@ -58,6 +58,6 @@ Compact tests pin low-friction service-wide defaults, actual routed-model select Pressure describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. -The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window or split one indivisible oversized message/tool unit. +The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk. This RFC supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam RFC](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 51d28fa243..594f396aaf 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -16,9 +16,9 @@ Status: implemented `agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。 -循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。监听器失败属于普通 turn 失败,绝不会进入模型请求恢复。 +循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。向外传播的监听器失败属于普通 turn 失败,绝不会进入模型请求恢复;compact-basic 会按下文所述在内部处理其预期的操作性失败。 -`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并继续使用完整历史。 +`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。 ### 请求恢复只覆盖最终模型边界 @@ -58,6 +58,6 @@ Status: implemented 压力描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 -代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 +代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 本 RFC 只取代[压缩能力接缝 RFC](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 0f210cbd8e..67129e9bd7 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -18,7 +18,7 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. 2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. -3. **Model-free companion** — `@deepseek-ai/dsh-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. +3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`. 4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation @@ -37,7 +37,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, it invokes optional `ctx.toolResultPrune`, remeasures the durable surface, and summarizes only if pruning did not restore safe pressure. -Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step and reconstructs its request from the durable log. No replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). +Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering @@ -57,7 +57,7 @@ Auto-compaction checks after **every successful** step, not once per turn. This A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. -**Single-unit overflow is out of scope, by design.** If a single retained unit — one closed step, or a large free node such as a pasted `user/message` — *alone* exceeds the budget, compaction cannot help and the next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over. +**Some single-unit overflow remains out of scope, by design.** Summary range selection cannot split an indivisible unit. The optional pruning companion can nevertheless repair a closed tool pair when text-bearing tool-result content is the removable bulk and the pruned remainder fits. Envelope-only pressure, an oversized indivisible non-tool node such as a pasted `user/message`, and a tool unit whose non-prunable remainder is still oversized remain outside compaction. Bounding those individual units is a separate concern; the harness can still break on them. This is named honestly rather than papered over. ### Head-anchoring: one auto checkpoint, always at the head @@ -95,8 +95,8 @@ The `compact/start … compact/end` bracket is justified, in order of what now d Two failure paths, both documented: -- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction. -- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and leaves the surface untouched. Post-step pressure warns and continues; overflow recovery delegates so the original provider error remains authoritative. +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — no summary replacement lands. The derived surface remains the durable surface present at `compact/start`: full history when pruning made no replacement, or the already-pruned history when it did. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction. +- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and lands no summary replacement. Post-step pressure warns and continues from the latest durable surface — full history if no replacement preceded the attempt, or the pruned surface if pruning already landed. Overflow recovery delegates only before any replacement; generation progress from earlier pruning authorizes a retry from that durable surface unless cancellation or disposal wins. `compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. @@ -111,12 +111,12 @@ Two failure paths, both documented: ## Consequences -- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. +- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. - **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`dsh-invariants`** treats fresh appended tool results as executions that require an open step and pending call, while provenance-backed replacements are turn-enclosed surface rewrites. Positional replacement and complete-source checks validate the rewritten node. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. +- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 085b165a2f..121bfd09f7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -5,9 +5,9 @@ Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. -This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator's boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## Tool Package Map diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index 5f35097603..c86aaa6c45 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -25,7 +25,7 @@ flowchart LR bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_coding_token_meter - plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-tool-result-prune"] + plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] cfg --> plugin_coding_tool_result_prune plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_coding_compact_basic @@ -60,7 +60,7 @@ flowchart LR | `bash` | `@deepseek-ai/dsh-bash-local` | | `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-tool-result-prune` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index ba86b99e81..3b246fe825 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -52,7 +52,7 @@ # Prune oversized tool output without a model call before summary compaction. - id: tool-result-prune - name: '@deepseek-ai/dsh-tool-result-prune' + name: '@deepseek-ai/dsh-compact-tool-result-prune' # Summarize an older range after measured pressure or a canonical provider overflow. # Service-wide policy provides pressure, retention, and one overflow-retry default. diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 6c06202a30..70ac92eafc 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -12,7 +12,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/README.md b/packages/compact/README.md index e251bfe442..d19343dc60 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -6,7 +6,7 @@ A compaction capability family (see [capability seams](../../docs/rfc/implemente |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | | `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | -| `tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | +| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | -The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers. +The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 7acc1d0351..197fcd6ab6 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,8 +9,8 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. -- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. -- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. +- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. +- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -Loading the plugin registers `ctx.compact`. Add [`dsh-tool-result-prune`](../tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. ## Model Experience @@ -120,7 +120,7 @@ Rules: - **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization. - **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. -- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix. +- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. -- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. +- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule. - **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 55ca301878..d242e3bed0 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -27,11 +27,11 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", - "@deepseek-ai/dsh-tool-result-prune": "^0.0.1", + "@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-tool-result-prune": { + "@deepseek-ai/dsh-compact-tool-result-prune": { "optional": true } }, @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 34567f7e07..bd33e84355 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -13,7 +13,7 @@ import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' // Type-only: makes the optional sibling service available to `ctx.get()`. -import type {} from '@deepseek-ai/dsh-tool-result-prune' +import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 094a22a11e..c2e09a5081 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,7 +9,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 7294224452..2035627f64 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -9,7 +9,7 @@ import Include from '@cordisjs/plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined let context: Context | undefined @@ -33,7 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmService], ['@deepseek-ai/dsh-token-meter', TokenMeterService], - ['@deepseek-ai/dsh-tool-result-prune', ToolResultPruneService], + ['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService], ['@deepseek-ai/dsh-compact-basic', BasicCompactService], ]) context.loader.internal = { @@ -58,7 +58,7 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-token-meter'", ' config:', ' contextWindow: 4096', - "- name: '@deepseek-ai/dsh-tool-result-prune'", + "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', ' headChars: 20', diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 5dd00b83f1..47d552c3f0 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -14,6 +14,6 @@ { "path": "../../core/session" }, { "path": "../../core/agent" }, { "path": "../compact" }, - { "path": "../tool-result-prune" } + { "path": "../compact-tool-result-prune" } ] } diff --git a/packages/compact/tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md similarity index 96% rename from packages/compact/tool-result-prune/README.md rename to packages/compact/compact-tool-result-prune/README.md index e6d50f229c..9c405af12f 100644 --- a/packages/compact/tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-tool-result-prune +# @deepseek-ai/dsh-compact-tool-result-prune The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log. @@ -28,7 +28,7 @@ All values are integers; the threshold is positive and head/tail are non-negativ ```ts import type { Context } from 'cordis' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { ctx.plugin(ToolResultPruneService) diff --git a/packages/compact/tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json similarity index 94% rename from packages/compact/tool-result-prune/package.json rename to packages/compact/compact-tool-result-prune/package.json index 6b7ac89742..81c81eb894 100644 --- a/packages/compact/tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-tool-result-prune", + "name": "@deepseek-ai/dsh-compact-tool-result-prune", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", "version": "0.0.1", "private": true, diff --git a/packages/compact/tool-result-prune/src/config.ts b/packages/compact/compact-tool-result-prune/src/config.ts similarity index 100% rename from packages/compact/tool-result-prune/src/config.ts rename to packages/compact/compact-tool-result-prune/src/config.ts diff --git a/packages/compact/tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts similarity index 99% rename from packages/compact/tool-result-prune/src/index.ts rename to packages/compact/compact-tool-result-prune/src/index.ts index 287b0f2500..c03fa81724 100644 --- a/packages/compact/tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -1,7 +1,7 @@ /** * Replay-safe, model-free tool-result pruning service. * - * @module @deepseek-ai/dsh-tool-result-prune + * @module @deepseek-ai/dsh-compact-tool-result-prune */ import { Context, Service } from 'cordis' diff --git a/packages/compact/tool-result-prune/src/types.ts b/packages/compact/compact-tool-result-prune/src/types.ts similarity index 100% rename from packages/compact/tool-result-prune/src/types.ts rename to packages/compact/compact-tool-result-prune/src/types.ts diff --git a/packages/compact/tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts similarity index 84% rename from packages/compact/tool-result-prune/tests/loader-composition.spec.ts rename to packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index fef9326d2a..db4c29ebdb 100644 --- a/packages/compact/tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined let context: Context | undefined @@ -18,12 +18,12 @@ afterEach(async () => { root = undefined }) -describe('tool-result-prune real Loader composition', () => { +describe('compact-tool-result-prune real Loader composition', () => { it('loads and resolves the flat YAML plugin shape', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-tool-result-prune-loader-')) + root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-tool-result-prune'", + "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', ' headChars: 20', @@ -38,7 +38,7 @@ describe('tool-result-prune real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-tool-result-prune') { + if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') { throw new Error(`unexpected Loader import: ${specifier}`) } return ToolResultPruneService diff --git a/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts similarity index 98% rename from packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts rename to packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 235ecdab52..b72c894891 100644 --- a/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -10,8 +10,8 @@ import ToolResultPruneService, { DEFAULTS, PRUNE_MARKER, resolveConfig, -} from '@deepseek-ai/dsh-tool-result-prune' -import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-tool-result-prune' +} from '@deepseek-ai/dsh-compact-tool-result-prune' +import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune' const SMALL: ToolResultPruneConfig = { thresholdChars: 50, diff --git a/packages/compact/tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json similarity index 100% rename from packages/compact/tool-result-prune/tsconfig.json rename to packages/compact/compact-tool-result-prune/tsconfig.json diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 40bbed7eba..1ee37e5b47 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -72,5 +72,5 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. -- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted. +- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted. - **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix. diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8657dca3ca..1ba64f6326 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -69,11 +69,6 @@ describe('gen-tool-catalog assertManifestComplete', () => { // is unlisted, so the guard must fire and name them. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) - try { - assertManifestComplete([]) - } catch (error) { - expect(String(error)).not.toContain('tool-result-prune') - } }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0935bde01..5de2ae6d61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,9 +256,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - '@deepseek-ai/dsh-tool-result-prune': + '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ - version: link:../tool-result-prune + version: link:../compact-tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -266,7 +266,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) - packages/compact/tool-result-prune: + packages/compact/compact-tool-result-prune: dependencies: schemastery: specifier: ^3.18.0 @@ -2140,9 +2140,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-tool-result-prune': + '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ - version: link:../../packages/compact/tool-result-prune + version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a436f859fc..7b0f6b6a56 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-result-prune": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index db6acf2c43..bfbaef1df6 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -96,7 +96,7 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'toolResultPrune', - pkg: 'tool-result-prune', + pkg: 'compact-tool-result-prune', title: 'Model-free tool-result pruning', mode: 'core', consumers: ['compact-basic'], diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4a0efe66a7..3e3a0ce81b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,9 +1,8 @@ /** * Generate `docs/tool-catalog.md` from schemas collected by booting each tool * plugin. Runtime registration is the source of truth for computed schemas; - * the manifest is checked against every on-disk model-facing `tool-*` package; - * non-model service packages with that prefix are explicitly excluded. - * `--check` verifies the committed artifact. Rationale and ownership live in + * the manifest is checked against every on-disk `tool-*` package. `--check` + * verifies the committed artifact. Rationale and ownership live in * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ @@ -39,9 +38,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -/** `tool-*` leaves that are runtime services, not contributors to `ctx.tools`. */ -const NON_MODEL_TOOL_PACKAGES = new Set(['tool-result-prune']) - /** * Tool package plus its hand-maintained boot recipe. The caller mounts the * prompt and registry; each recipe supplies only package-specific seams and @@ -81,10 +77,9 @@ interface ToolPackage { } /** - * The boot manifest: every shipped model-facing tool package (a `tool-*` leaf - * under `packages/`, excluding {@link NON_MODEL_TOOL_PACKAGES}). Ordered by - * package name (the render order); the completeness guard proves it is - * exhaustive against the filtered on-disk glob. + * The boot manifest: every shipped tool package (a `tool-*` leaf under + * `packages/`). Ordered by package name (the render order); the completeness + * guard proves it is exhaustive against the on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ { @@ -261,9 +256,8 @@ interface CatalogPackage { export type ToolCatalog = CatalogPackage[] /** - * Assert the boot manifest covers every shipped model-facing tool package on - * disk (a `tool-*` leaf under `packages/`, excluding explicit service-only - * entries in {@link NON_MODEL_TOOL_PACKAGES}). + * Assert the boot manifest covers every shipped tool package on disk (a + * `tool-*` leaf under `packages/`). * Booting has no source declaration to enumerate, so this glob restores the * "a new tool cannot be silently undocumented" guarantee: an unlisted package * fails the generator (and the freshness gate) until it is added to @@ -272,10 +266,7 @@ export type ToolCatalog = CatalogPackage[] * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. */ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { - const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }) - .map(p => basename(p)) - .filter(dir => !NON_MODEL_TOOL_PACKAGES.has(dir)) - .sort() + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() const listed = new Set(packages.map(p => p.dir)) const missing = onDisk.filter(dir => !listed.has(dir)) if (missing.length > 0) { @@ -348,9 +339,9 @@ export function render(catalog: ToolCatalog): string { '', 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', - 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator\'s boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', '## Tool Package Map', '', diff --git a/tsconfig.build.json b/tsconfig.build.json index a099fc3a23..6cdef1f6c9 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -38,7 +38,7 @@ { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, - { "path": "./packages/compact/tool-result-prune" }, + { "path": "./packages/compact/compact-tool-result-prune" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, diff --git a/tsconfig.json b/tsconfig.json index 1decda2db0..9cc7c5ce83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,7 +60,7 @@ { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, - { "path": "./packages/compact/tool-result-prune" }, + { "path": "./packages/compact/compact-tool-result-prune" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, From a425aff586a5721a4ecd39dbc1e5be183794b092 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:52:35 +0800 Subject: [PATCH 017/132] docs(rfc): propose one-send-one-turn simplification --- docs/rfc/INDEX.md | 1 + .../2026-07-17-one-send-one-turn.i18n.yaml | 6 ++++ .../2026-07-17-one-send-one-turn.md | 36 +++++++++++++++++++ .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..c0992414af 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,6 +22,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml new file mode 100644 index 0000000000..d485306ae6 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c +2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..2542b1038b --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,36 @@ +# RFC: Give each ordinary send its own turn + +Status: proposed + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. + +A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. + +## Proposal + +The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. + +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. + +Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. + +## Acceptance criteria + +- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. +- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. +- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. + +## Risks + +Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..eefe6f2cc4 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,36 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: proposed + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 提案 + +Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 + +Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 + +运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 + +## 验收标准 + +- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 +- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 + +## 风险 + +依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 From 97c5ca940d449d5a4b164984dc2ffcc6878ef841 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:58:56 +0800 Subject: [PATCH 018/132] review fix: preserve blocked-prompt RFC contract --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../simplification/2026-07-17-one-send-one-turn.md | 4 ++-- .../2026-07-17-one-send-one-turn.zh.md | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml index d485306ae6..36bd0699c4 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c -2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 +2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b +2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md index 2542b1038b..21ab23a9ab 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -16,7 +16,7 @@ A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the du The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. @@ -28,7 +28,7 @@ Running `steer()` will continue to append to the active turn's steering FIFO. Id - Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. - Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md index eefe6f2cc4..07e268fcfa 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,27 +8,27 @@ Status: proposed `Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 -共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 +共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 提案 Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 -Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 +提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 -运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 ## 验收标准 - 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 ## 风险 From aa4e62987473c878eaf1a0ee117ebcd8b6327337 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:14:52 +0800 Subject: [PATCH 019/132] feat(agent-loop): give each send its own turn --- docs/architecture.md | 10 +- docs/core-data-structures/core.md | 11 +- docs/core-data-structures/session.md | 12 +- docs/defensive-patterns.md | 2 +- docs/persistence-catalog.md | 4 +- docs/rfc/INDEX.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 38 +++++ .../2026-07-17-one-send-one-turn.zh.md | 38 +++++ .../2026-07-17-one-send-one-turn.md | 36 ----- .../2026-07-17-one-send-one-turn.zh.md | 36 ----- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/inbox.ts | 12 +- packages/core/agent-loop/src/loop.ts | 60 +++----- packages/core/agent-loop/tests/cancel.spec.ts | 9 +- .../tests/contract-regressions.spec.ts | 40 ++++-- .../agent-loop/tests/coverage-edges.spec.ts | 16 ++- packages/core/agent-loop/tests/inbox.spec.ts | 10 +- .../agent-loop/tests/interception.spec.ts | 38 ++--- packages/core/agent-loop/tests/loop.spec.ts | 130 +++++++++++++++++- .../core/agent-loop/tests/properties.spec.ts | 41 ++++-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 8 +- packages/core/session/src/types.ts | 10 +- packages/ui/acp/src/index.ts | 4 +- 26 files changed, 372 insertions(+), 209 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-17-one-send-one-turn.i18n.yaml (65%) create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..b0449be8ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is one agent's append-only event log. An ordinary **turn** claims one queued message; an injection turn claims none. A turn ends when the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. ### Turn Flow @@ -64,13 +64,13 @@ prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued messages + wait for a queued message emit agent/status(running) TURN: 'turn/start' - each queued message -> agent/prompt-submit + claimed message -> agent/prompt-submit allowed prompt -> 'user/message' plus injected context - every prompt blocked -> 'turn/end'(rejected) + blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) STEP loop: drain steering assemble system prompt and tool schemas @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step has one prompt assembly. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unresolved references fail the turn. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 340212d37a..8a78dbc79c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -262,8 +262,9 @@ interface Agent { readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. Unless cleared before turn start, the + * item becomes the sole ordinary message in its turn and waits for every + * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly @@ -308,8 +309,8 @@ interface Agent { * - drops a turn that is about to start (a `cancel()` landing in the * pre-step window — after a `send()` queued but before the loop flips to * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. + * that queued prompt does not run; later accepted items remain independent + * queued turns. * * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. * `cancel()` on an idle agent with nothing queued or running is a safe no-op @@ -364,7 +365,7 @@ interface HookContext { } ``` -`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContext` — or record `prompt/blocked` and end that zero-step turn as `rejected`): ```ts type-equiv type PromptDecision = diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..18d511a0a0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -19,9 +19,8 @@ interface SessionEventMap { /** * A queued prompt an `agent/prompt-submit` listener VETOED — the durable * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` + * the prompt would have become; that one-message turn runs zero steps and + * ends `rejected`. `content` * is the original prompt the listener rejected; `reason` is the veto text * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a * blocked prompt produces no LLM message and never reaches `deriveMessages()`. @@ -268,9 +267,8 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block + * The turn's claimed prompt was BLOCKED before any step ran by an + * `agent/prompt-submit` listener (a hook). The turn still opened (so the boundary stays balanced and the block * is a durable in-turn fact), but ran zero steps. `reason` carries the block * message from the vetoing decision. Distinct from `aborted` (a user-driven * cancel) and `error` (a failure): the prompt was rejected by policy, not @@ -291,7 +289,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose whole prompt batch an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose claimed prompt an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. ## The turn-enclosure invariant diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index cf30072094..fe74a9d19f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma ## Async state is not synchronous state -`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns (the loop batches queued messages). The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a27b19b9a7..64a0625f36 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -169,7 +169,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, and its turn runs zero steps. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -305,7 +305,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ #### `turn/start` — log-only -Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). +Opens turn `turn`. `trigger` records what started it — one claimed queued message or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). ```ts persistence-catalog 'turn/start': { turn: number; trigger: TurnTrigger } diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c0992414af..b382cbc945 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,7 +22,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | -| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture @@ -108,6 +107,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | +| [Give each ordinary send its own turn](implemented/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..669e577625 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,7 +14,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. @@ -30,11 +30,11 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. -**`TurnEndReason.rejected`** (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. +**`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while allowed `additionalContext` is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml similarity index 65% rename from docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml rename to docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 36bd0699c4..bcb9485da5 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b -2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a +2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f +2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..34331a04b5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,38 @@ +# RFC: Give each ordinary send its own turn + +Status: implemented + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. + +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. + +## Decision + +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. + +Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. + +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. + +## Verification + +- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. +- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. + +## Consequences + +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. + +Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..e6c4b97e82 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,38 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: implemented + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 决策 + +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 + +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 + +运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 + +## 验证 + +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 +- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 + +## 后果 + +普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 + +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md deleted file mode 100644 index 21ab23a9ab..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: Give each ordinary send its own turn - -Status: proposed - -English | [中文](2026-07-17-one-send-one-turn.zh.md) - -## Problem - -`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. - -A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. - -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. - -## Proposal - -The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. - -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. - -Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. - -## Alternatives considered - -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. - -## Acceptance criteria - -- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. -- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. - -## Risks - -Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md deleted file mode 100644 index 07e268fcfa..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: 让每次普通 send 独占一个轮次 - -Status: proposed - -[English](2026-07-17-one-send-one-turn.md) | 中文 - -## 问题 - -`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 - -共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 - -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 - -## 提案 - -Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 - -提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 - -运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 - -## 曾考虑的替代方案 - -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 - -## 验收标准 - -- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 - -## 风险 - -依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..88efc953a7 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index abb588b919..b26a79a1ef 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -15,7 +15,7 @@ export interface InboxMessage { } /** - * Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO + * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of * the loop — the public surface is `Agent.send()` / `Agent.steer()`. */ @@ -54,11 +54,11 @@ export class Inbox { } /** - * Drain all queued messages (turn start). - * @returns the drained messages in arrival order; the queued FIFO is left empty. + * Remove the oldest queued message for one turn start. + * @returns the oldest message, or `undefined` when the queued FIFO is empty. */ - drainQueued(): InboxMessage[] { - return this.queuedMessages.splice(0) + dequeueQueued(): InboxMessage | undefined { + return this.queuedMessages.shift() } /** @@ -72,7 +72,7 @@ export class Inbox { /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into - * a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away. + * a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away. */ clear(): void { this.queuedMessages.length = 0 diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 15cc0aa410..ebe528c5e0 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -88,7 +88,7 @@ export interface LoopHandle { } /** - * Drive queued batches as durable turns until disposal. Plugin failures end the + * Drive queued messages as independent durable turns until disposal. Plugin failures end the * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). @@ -159,12 +159,11 @@ async function runTurn( ): Promise { const { session } = agent - // Drain before opening the turn, but append only after `turn/start`. - const queued = handle.inbox.drainQueued() - const first = queued[0] + // Claim one queued message before opening its turn, but append it only after `turn/start`. + const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ - if (!first) throw new Error('runTurn invariant violated: no queued message at turn start') - const trigger: TurnTrigger = { kind: 'message', source: first.source } + if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } let step = 0 @@ -202,51 +201,32 @@ async function runTurn( // matter what throws below; the catch + closeTurn guarantee it. A pre-commit // veto leaves no turn/start in the log and therefore owes no turn/end. session.append('turn/start', { turn, trigger }) - // Each drained queued message runs the `agent/prompt-submit` waterfall before - // it becomes a `user/message` — a hook can rewrite the prompt or block it. + // The claimed message runs the `agent/prompt-submit` waterfall before it + // becomes a `user/message` — a hook can rewrite the prompt or block it. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; // turn/end is now owed, so a throwing prompt-submit listener (the waterfall // throws) is caught below and the turn still closes. - let anyAllowed = false - // Seeded with a floor (only observable if the batch were empty, which - // runTurn never allows — it is called with ≥1 queued message); each `block` - // decision carries a required `reason` and overwrites it, so a fully-blocked - // batch always reports the last vetoing reason. - let lastBlockReason = 'prompt blocked by hook' - for (const message of queued) { - const decision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, - () => Promise.resolve({ kind: 'allow' }), - ) - if (decision.kind === 'block') { - lastBlockReason = decision.reason - // Record the veto durably: `PromptDecision.reason` is the durable record - // of why a prompt was blocked, but a fully-blocked batch's `rejected` - // turn/end only preserves the LAST reason, and a MIXED batch (this prompt - // blocked, another allowed) does not end `rejected` at all — so without - // this append a blocked prompt would vanish from the log whenever any - // sibling prompt is allowed. `prompt/blocked` sits in the open turn in - // place of the `user/message` this prompt would have become. - session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason }) - continue - } - anyAllowed = true + const promptDecision = await events.waterfall( + 'agent/prompt-submit', message.content, message.source, + () => Promise.resolve({ kind: 'allow' }), + ) + if (promptDecision.kind === 'block') { + session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) + reason = { kind: 'rejected', reason: promptDecision.reason } + } else { // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. - const content = decision.content ?? message.content + const content = promptDecision.content ?? message.content session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) // `allow.additionalContext` is a SEPARATE context/message the next request // also sees. The turn is open, so inject() appends it into THIS turn. - if (decision.additionalContext) { - agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source }) + if (promptDecision.additionalContext) { + agent.inject(promptDecision.additionalContext.content, { source: promptDecision.additionalContext.source }) } } while (true) { - // A fully blocked batch closes its zero-step turn as rejected. - if (!anyAllowed) { - reason = { kind: 'rejected', reason: lastBlockReason } - break - } + // A blocked prompt closes its zero-step turn as rejected. + if (promptDecision.kind === 'block') break step += 1 // Steering from the previous round's continuation listeners joins before diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cad830e827..5ed15f1b0b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -75,7 +75,8 @@ describe('Agent.cancel()', () => { // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. - send(agent, 'drop me') + send(agent, 'drop me first') + send(agent, 'drop me second') agent.cancel('pre-step') // Give the loop a chance to wake and process the cancel. @@ -106,7 +107,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) - it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -117,10 +118,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + send(agent, 'queued tail') agent.cancel('mid-step') await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }]) + expect(userTexts(agent)).toEqual(['go']) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(adapter.requests).toHaveLength(1) }) it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ee50261e9d..33e0b7de9c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -276,15 +276,20 @@ describe('plugin exceptions are contained', () => { expect(agent.status).toBe('idle') }) - it('a rejecting session/flush listener is reported but does not kill the agent', async () => { + it('a rejecting first-turn flush settles before the queued tail starts', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let rejectedOnce = false - ctx.on('session/flush', async () => { - if (!rejectedOnce) { - rejectedOnce = true + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise throw new Error('disk full') } }) @@ -292,18 +297,25 @@ describe('plugin exceptions are contained', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['disk full']) - send(agent, 'second') - await waitForIdle(ctx, agent) + + await firstFlush.promise + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(errors.map(e => e.message)).toEqual(['disk full']) expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) }) }) describe('disposed status is part of the agent/status contract', () => { - it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { + it('disposing the fiber ends the active turn and never starts its queued tail', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -319,11 +331,19 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) + send(agent, 'queued tail') await fiber.dispose() await agent.done expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .flatMap(event => event.data.content) + .flatMap(block => block.type === 'text' ? [block.text] : []) + expect(messages).toEqual(['go']) + expect(adapter.requests).toHaveLength(1) }) it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 5deee8e159..c85c5ef949 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -141,12 +141,22 @@ describe('toError normalization', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - send(agent, 'go') + send(agent, 'fails before turn start') + send(agent, 'survives as the next item') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' }) - expect(adapter.requests).toEqual([]) - expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false) + expect(adapter.requests).toHaveLength(1) + const starts = agent.session.events.filter(event => event.type === 'turn/start') + const ends = agent.session.events.filter(event => event.type === 'turn/end') + const messages = agent.session.events.filter(event => event.type === 'user/message') + expect(starts).toHaveLength(1) + expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1) + expect(ends).toHaveLength(1) + expect(messages).toHaveLength(1) + expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([ + { type: 'text', text: 'survives as the next item' }, + ]) }) it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 4bea62abe2..f4eea9fdd0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -8,17 +8,17 @@ function resolverPair() { } describe('Inbox', () => { - it('enqueues and drains queued messages in FIFO order', () => { + it('dequeues one queued message at a time in FIFO order', () => { const inbox = new Inbox() inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }) inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }) expect(inbox.hasQueued).toBe(true) - const drained = inbox.drainQueued() - expect(drained).toHaveLength(2) - expect(drained[0]!.content[0]).toMatchObject({ text: 'first' }) - expect(drained[1]!.content[0]).toMatchObject({ text: 'second' }) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' }) + expect(inbox.hasQueued).toBe(true) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' }) expect(inbox.hasQueued).toBe(false) + expect(inbox.dequeueQueued()).toBeUndefined() }) it('pushes and drains steering messages separately from queued', () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 4b37210993..ad0ea7d199 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -182,9 +182,7 @@ describe('agent/prompt-submit', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) }) - it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { - // Blocking one prompt in a mixed batch must persist its reason even though - // the allowed prompt keeps the turn from ending rejected. + it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => { const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -197,13 +195,13 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - // both sends land before the loop drains → one batched turn + // Both sends land before the driver wakes, but each remains its own turn. send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) const log = events(agent) - // the allowed prompt became a user/message and drove exactly one model call + // The allowed prompt became a user/message and drove exactly one model call. const userMsgs = log.filter(e => e.type === 'user/message') expect(userMsgs).toHaveLength(1) expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) @@ -215,12 +213,14 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'secret' }], reason: 'policy: no secrets', }) - // the turn did NOT reject — a sibling was allowed — so the boundary reason - // alone would not have preserved the block - expect(reasons.some(r => r.kind === 'rejected')).toBe(false) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'rejected', reason: 'policy: no secrets' }, + { kind: 'completed' }, + ]) }) - it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { + it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -233,18 +233,18 @@ describe('agent/prompt-submit', () => { const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // turn balanced - const log = events(agent) - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) - - // loop survives: a second prompt runs normally send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + await idle + expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) + // The failed prompt owns one balanced error turn; the adjacent prompt owns + // the following normal turn without an intermediate idle transition. + const log = events(agent) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(adapter.requests).toHaveLength(1) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..368ce0c6f1 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -827,7 +827,104 @@ describe('agent loop', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) - it('chains queued messages into consecutive turns', async () => { + it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + send(agent, 'second message') + + await firstFlush.promise + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(flushes).toBe(2) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a reentrant agent/queued send as the next independent turn', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let nested = false + ctx.on('agent/queued', (subject) => { + if (subject !== agent || nested) return + nested = true + send(agent, 'queued listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const turns = agent.session.events.filter(event => event.type === 'turn/start') + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(turns).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'queued listener message' }], + ]) + }) + + it('preserves independent turn sources across an adjacent microtask send', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const idle = waitForIdle(ctx, agent) + agent.send([{ type: 'text', text: 'user message' }]) + await Promise.resolve() + agent.send( + [{ type: 'text', text: 'plugin message' }], + { source: { kind: 'plugin', plugin: 'test' } }, + ) + await idle + + const triggers = agent.session.events + .filter(event => event.type === 'turn/start') + .map(event => event.data.trigger) + const sources = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.source) + expect(triggers).toEqual([ + { kind: 'message', source: { kind: 'user' } }, + { kind: 'message', source: { kind: 'plugin', plugin: 'test' } }, + ]) + expect(sources).toEqual([ + { kind: 'user' }, + { kind: 'plugin', plugin: 'test' }, + ]) + }) + + it('keeps a session-listener send after dequeue in the following turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -850,6 +947,37 @@ describe('agent loop', () => { expect(turns).toEqual([1, 2]) expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a model-adapter callback send in the following turn', async () => { + const agentRef: { current?: ReactLoopAgent } = {} + const adapter = new MockAdapter([ + () => { + const agent = agentRef.current + if (agent === undefined) throw new Error('model callback ran before agent setup') + send(agent, 'model callback message') + return textResponse('first') + }, + textResponse('second'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agentRef.current = agent + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'model callback message' }], + ]) }) it('awaits session/flush at turn end (persistence checkpoint)', async () => { diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index dbc43ad985..82b103d664 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -75,6 +75,21 @@ function turnNumbers(agent: ReactLoopAgent): number[] { .map(e => (e.data as { turn: number }).turn) } +function turnEndNumbers(agent: ReactLoopAgent): number[] { + return agent.session.events + .filter(e => e.type === 'turn/end') + .map(e => (e.data as { turn: number }).turn) +} + +function userMessageCountsByTurn(agent: ReactLoopAgent): number[] { + const counts: number[] = [] + for (const event of agent.session.events) { + if (event.type === 'turn/start') counts.push(0) + if (event.type === 'user/message') counts[counts.length - 1]! += 1 + } + return counts +} + /** Assert a status trace is a legal run: idle/running alternating, ending idle. */ function assertLegalStatusTrace(trace: string[]): void { for (let i = 1; i < trace.length; i++) { @@ -84,7 +99,7 @@ function assertLegalStatusTrace(trace: string[]): void { } describe('agent loop scheduling properties', () => { - it('a synchronous burst loses no message and uses strictly increasing turns', async () => { + it('a synchronous burst gives every message its own strictly increasing turn', async () => { await fc.assert(fc.asyncProperty( fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }), async (texts) => { @@ -99,8 +114,11 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // A synchronous burst batches into exactly one turn. - expect(turnNumbers(agent)).toEqual([1]) + // Every successful send owns an independent turn even before the driver wakes. + expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) + expect(trace).toEqual(['running', 'idle']) assertLegalStatusTrace(trace) } finally { await ctx.fiber.dispose() @@ -131,9 +149,9 @@ describe('agent loop scheduling properties', () => { ), { numRuns: 20, timeout: 2000 }) }) - it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => { - // Each step is a (text, settle?) pair: settle=true awaits idle before the - // next send (own turn); settle=false sends in the same tick (batches). + it('mixed settled and same-tick sends preserve one turn per message', async () => { + // Each step optionally waits for idle before the next send; that scheduling + // choice must not change the ordinary message-to-turn mapping. const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() }) await fc.assert(fc.asyncProperty( fc.array(stepArb, { minLength: 1, maxLength: 6 }), @@ -152,14 +170,13 @@ describe('agent loop scheduling properties', () => { } await lastIdle - // No message lost or reordered, regardless of batching. + // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Turn numbers are a strictly increasing 1..N prefix (N = turn count). + // Every send owns exactly one turn, numbered in FIFO order. const turns = turnNumbers(agent) - expect(turns).toEqual(turns.map((_, i) => i + 1)) - // Every message landed in some turn; turns never exceed messages. - expect(turns.length).toBeLessThanOrEqual(steps.length) - expect(turns.length).toBeGreaterThanOrEqual(1) + expect(turns).toEqual(steps.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(turns) + expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1)) } finally { await ctx.fiber.dispose() } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..e1e5b27f22 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -39,7 +39,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..62d5b2695e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,7 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and * `additionalContext` becomes a separate context message. `block` records a - * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. + * durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } @@ -90,7 +90,7 @@ export interface Agent { readonly ctx: Context /** - * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -111,8 +111,8 @@ export interface Agent { inject(content: ContentBlock[], options?: SendOptions): void /** - * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active + * Clear all queued and steering work, including items waiting to start, and + * abort the active step. The supplied reason is preserved across pre-step and active * cancellation windows, and `whenIdle()` resolves after cancellation reaches * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..131deec325 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -105,8 +105,8 @@ export interface TurnEndReasonMap { /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * Policy blocked every prompt before the first step. The zero-step turn still - * records a balanced durable boundary and the veto reason. + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. */ rejected: { kind: 'rejected'; reason: string } /** @@ -209,8 +209,8 @@ export interface ToolsDelta { */ export interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started it — a drained message - * batch or an idle-time injection. The turn is the durability/replay + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay * boundary: every event sits between a `turn/start` and its matching * `turn/end` (the turn-enclosure invariant). */ @@ -229,7 +229,7 @@ export interface SessionEventMap { 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, including in a mixed batch. + * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c464ffd22b..2907334b69 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -709,8 +709,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // session/cancel maps to the queue-aware agent.cancel(reason): it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a // turn that is about to start (the pre-step window) — so a queued-but- - // not-yet-started prompt never runs, and a prompt accepted right after - // cannot be batched into the cancelled turn. Scoped to THIS session's + // not-yet-started prompt never runs, while a prompt accepted afterward + // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to From 2cf689301c337c53739e4cebcc37929c06e325ff Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:27:53 +0800 Subject: [PATCH 020/132] review fix: close one-turn lifecycle gaps --- packages/core/agent-loop/src/loop.ts | 1 + packages/core/agent-loop/tests/cancel.spec.ts | 30 +++++++++ .../agent-loop/tests/interception.spec.ts | 11 ++++ packages/core/agent-loop/tests/loop.spec.ts | 65 +++++++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b26d09e5c1..80b6b27eaf 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -117,6 +117,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } handle.setStatus('running') + if (handle.isDisposed()) break // A synchronous `running` listener can cancel before `runTurn`; balance the // status only when no replacement prompt was queued by that listener. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5ed15f1b0b..dd02afdec7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -88,6 +88,36 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('disposal from the running notification drops queued work before turn start', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('a-dispose-running'), + sessionId: SessionId('dispose-running-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + + const running = Promise.withResolvers() + let disposalDone: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'running') return + disposalDone = handle.dispose() + running.resolve(undefined) + }) + + send(agent, 'drop before claim') + await running.promise + if (disposalDone === undefined) throw new Error('running listener did not start disposal') + await disposalDone + await agent.done + + expect(agent.status).toBe('disposed') + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + }) + it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 57b773de8a..8663875c6b 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -239,7 +239,13 @@ describe('agent/prompt-submit', () => { return { kind: 'allow' as const } }) const errors: Error[] = [] + const reasons: TurnEndReason[] = [] + const statuses: string[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) + }) const idle = waitForIdle(ctx, agent) send(agent, 'first') @@ -251,6 +257,11 @@ describe('agent/prompt-submit', () => { const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'error', step: 0, message: 'prompt hook broke' }, + { kind: 'completed' }, + ]) + expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(1) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index aff13ddd5c..0e223563b7 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -349,14 +349,24 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('steering while idle behaves like send (starts a turn)', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.steer([{ type: 'text', text: 'hello' }]) - await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'first idle steer' }]) + agent.steer([{ type: 'text', text: 'second idle steer' }]) + await idle + + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content)).toEqual([ + [{ type: 'text', text: 'first idle steer' }], + [{ type: 'text', text: 'second idle steer' }], + ]) + expect(adapter.requests).toHaveLength(2) }) it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { @@ -893,6 +903,51 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') }) + it('holds a turn-end listener send behind the closing turn checkpoint', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + await firstFlush.promise + + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') + }) + it('keeps a reentrant agent/queued send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) From 0b720942dfe6e8e018d537b80364dfbd201ed27e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:31:35 +0800 Subject: [PATCH 021/132] review fix: qualify one-turn documentation --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 7 +++--- .../feature/2026-06-30-interception-seams.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../2026-07-17-one-send-one-turn.md | 8 +++---- .../2026-07-17-one-send-one-turn.zh.md | 24 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 2 +- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 063c805272..627776bbe0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each successful `send()` adds one FIFO item. A claimed item is the sole ordinary message in its turn and waits for the prior checkpoint to settle; cancellation, disposal, or a pre-start failure may drop it without a turn. Consecutive claimed items run under one `running` interval. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 072ad629a6..0d8c874704 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -271,9 +271,10 @@ interface Agent { readonly ctx: Context /** - * Queue one user-message FIFO item. Unless cleared before turn start, the - * item becomes the sole ordinary message in its turn and waits for every - * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. If claimed, the item becomes the sole + * ordinary message in its turn after every preceding turn's checkpoint + * settles. Broad cancellation, disposal, or a pre-start failure can drop it + * without a turn. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 50ff4a3af6..aef2ef4eb5 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each claimed ordinary-send item is the sole message in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md); a pre-start drop creates no turn. 2. **Post-tool `additionalContexts` are buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but each context is a SEPARATE `context/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop appends every entry only after every `tool/result` in the step. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index bcb9485da5..1d3181bb0a 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f -2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 +2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 +2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md index 34331a04b5..232e91b1d1 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,7 +8,7 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. @@ -18,11 +18,11 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. ## Verification @@ -33,6 +33,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e6c4b97e82..b4dc8d37a0 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -6,33 +6,33 @@ Status: implemented ## 问题 -每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 +每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 -- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 88efc953a7..e899da75fc 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item and shares that accepted record between `agent/queued` and the inbox. If claimed, the item becomes the sole ordinary message in a turn after preceding checkpoints settle; cancellation, disposal, or a pre-start failure can drop it first. Malformed data throws before either acceptance boundary. Running `steer()` uses the same boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8e86700835..e56eb8cf94 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (`context/message` event); the next request sees it. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. From 915c727e14ceeb0b3bcbc696ca779d399c555fce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:38:39 +0800 Subject: [PATCH 022/132] review fix: distinguish checkpoint settlement --- docs/core-data-structures/persistence.md | 2 +- docs/persistence-catalog.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 12 ++++++------ packages/core/agent-loop/tests/properties.spec.ts | 4 ++-- packages/core/session/src/types.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..3ae22d05d1 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06 ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. Flush is `ctx.parallel` (awaited): a turn's events are durably committed before the next turn starts, and the turn boundary is the commit boundary. A rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the commit boundary), so the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6c48285d99..9678d25594 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -293,7 +293,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ #### `turn/end` — log-only -Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. +Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. Success commits the closed turn; rejection is reported live and does not prevent later work. ```ts persistence-catalog 'turn/end': { turn: number; reason: TurnEndReason } diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 1d3181bb0a..2d926e45c3 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 -2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 +2026-07-17-one-send-one-turn.zh.md: c533e0fa35a73056b2bb5ae6ca8a53e5757c55cf diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index b4dc8d37a0..c533e0fa35 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,17 +8,17 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -28,11 +28,11 @@ Status: implemented - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 +- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 82b103d664..1dd1a0c127 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -114,7 +114,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // Every successful send owns an independent turn even before the driver wakes. + // This failure-free fixture claims every item into an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -172,7 +172,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every send owns exactly one turn, numbered in FIFO order. + // Every item is claimed and therefore owns one FIFO-ordered turn. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f9de8962ac..dfc8a57fcd 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -221,8 +221,8 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end, so the turn - * boundary is also the durable-commit boundary. + * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. + * Success commits the closed turn; rejection is reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ From 6ce407259927bd73dffdf0a3643111b45651af87 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 18 Jul 2026 21:53:30 +0800 Subject: [PATCH 023/132] fix(ci): avoid concurrent docs site builds --- scripts/run-gates.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9eb82b5e15..4da4998723 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -214,7 +214,6 @@ function ciPrimaryGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - pnpmScript('website-build', 'website:build', { label: 'website build' }), pnpmScript('build', 'build', { needs: ['typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { @@ -234,7 +233,6 @@ function ciStaticGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - pnpmScript('website-build', 'website:build', { label: 'website build' }), ] } @@ -349,6 +347,7 @@ function docSyncLeafGates(options: { pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), + // Keep the VitePress build in this single gate because projection rewrites website/.generated. pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), ] From 0d00106fa8a25c148eb67538210104f010cec70d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 17:20:36 +0800 Subject: [PATCH 024/132] fix(session): persist the delegation depth in the session header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subagent child's recursion depth lived only in runtime AgentOptions, so a persisted child came back from resume counted as top-level and maxDepth stopped binding after every restart. Add SessionHeader.delegationDepth, round-trip it through the JSONL and SQLite backends (SQLite schema v5), restore it on agent-loop resume, and write it when the in-process backends create a child. The seam now owns the shared depth vocabulary (delegationDepthOf): the persisted header is authoritative and monotone — runtime options may deepen it but never lower it. --- docs/cordis-catalog/events.md | 8 ++-- docs/cordis-catalog/services.md | 6 +-- docs/core-data-structures/persistence.md | 7 ++++ docs/event-producer-consumer.md | 8 ++-- docs/persistence-catalog.md | 30 ++++++------- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +-- packages/core/agent-loop/src/index.ts | 1 + packages/core/agent-loop/tests/resume.spec.ts | 7 +++- packages/core/agent/src/index.ts | 12 ++++-- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 5 +++ packages/core/session/src/types.ts | 7 ++++ packages/core/session/tests/session.spec.ts | 16 +++++++ .../session-persistence-jsonl/src/format.ts | 3 ++ .../session-persistence-sqlite/src/index.ts | 8 ++-- .../session-persistence-sqlite/src/schema.ts | 11 +++-- .../tests/sqlite.spec.ts | 2 +- .../tests/coordinator-contract.ts | 21 ++++++++++ .../subagent/subagent-inprocess/README.md | 4 +- .../subagent/subagent-inprocess/src/index.ts | 27 ++---------- .../tests/subagent-inprocess.spec.ts | 42 ++++++++++++++++++- packages/subagent/subagent/README.md | 4 ++ packages/subagent/subagent/src/index.ts | 26 ++++++++++++ website/zh-CN/api/harness/agents.md | 30 ++++++------- website/zh-CN/api/harness/events.md | 8 ++-- website/zh-CN/api/harness/sessions.md | 18 ++++---- website/zh-CN/api/harness/subagents.md | 10 ++--- 29 files changed, 229 insertions(+), 106 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f522790fd0..5bd4d7b6f8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -542,7 +542,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -559,7 +559,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -574,7 +574,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:118`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -596,7 +596,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:129`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 267c2f0caa..378bb03ae8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:223`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -820,7 +820,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:581`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -934,7 +934,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:179`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 72ebcc5852..c4cbf97cdf 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -60,6 +60,12 @@ interface SessionHeader { * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Delegation depth: absent (zero) for a top-level session, parent depth + 1 + * for a subagent child. Persisted so a recursion budget survives restart and + * resume — a runtime-only depth would reset a resumed child to top-level. + */ + readonly delegationDepth?: number } ``` @@ -85,6 +91,7 @@ interface CreateSessionOptions { readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly delegationDepth?: number } } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 78f878106c..8f70dd00b6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:118`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:129`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3bc15253a5..2a0500d79b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) ### `bash/*` @@ -258,7 +258,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) ### `hook/*` @@ -336,7 +336,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:211`](../packages/core/session/src/types.ts) ### `request/*` @@ -350,7 +350,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) ### `steering/*` @@ -363,7 +363,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) ### `step/*` @@ -374,7 +374,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +383,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `todo/*` @@ -396,7 +396,7 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) ### `tool/*` @@ -413,7 +413,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -457,7 +457,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) ### `turn/*` @@ -474,7 +474,7 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -490,7 +490,7 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) ### `user/*` @@ -503,4 +503,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index b4e8cd6340..d8affeaab8 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index d8d2e59e6e..f0b6154472 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5d99506c8a..65f828c383 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -709,11 +709,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', }, { name: 'DiffCallView', @@ -929,7 +929,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 2a77afc983..519edfdb7e 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -625,6 +625,7 @@ export class AgentLoop extends Service implements AgentFactory { ...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd }, ...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession }, ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, + ...loaded.meta.delegationDepth === undefined ? {} : { delegationDepth: loaded.meta.delegationDepth }, }, }) const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 74192dde90..452f1d66b5 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -411,7 +411,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { + it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => { // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength // in its header) by creating it with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. @@ -423,7 +423,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, - meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length }, + meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 }, }) await ctx1.parallel('session/flush', forked) await ctx1.fiber.dispose() @@ -447,6 +447,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) + // The recursion budget survives resume — a dropped depth would let a + // resumed child delegate as if it were top-level. + expect(a2.session.header.delegationDepth).toBe(1) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 75f00daebb..061ddae53f 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -46,15 +46,21 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, and the `seedLength` seed boundary. Mirrors the - * `cwd`/`parentSession`/`seedLength` fields of + * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` + * recursion budget. Mirrors the + * `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, * so the session boundary validates and snapshots it before asynchronous * setup begins. */ - readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } + readonly meta?: { + readonly cwd?: string + readonly parentSession?: SessionId + readonly seedLength?: number + readonly delegationDepth?: number + } /** * Seed events to reconstruct the child session's log from (the fork lineage * primitive). When present, the factory creates the session with this event diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ba4e8ea94a..ec7daf9408 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -38,7 +38,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -73,7 +73,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b8b76ec2ed..37025ed1c7 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -128,6 +128,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) { throw new Error('session header seedLength must be a non-negative safe integer') } + if (record.delegationDepth !== undefined + && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { + throw new Error('session header delegationDepth must be a non-negative safe integer') + } return deepFreeze(record as unknown as SessionHeader) } @@ -650,6 +654,7 @@ export class SessionStore extends Service { ...meta?.cwd === undefined ? {} : { cwd: meta.cwd }, ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, } return new Session(sessionId, seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index dc34760e9c..31903d97db 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -50,6 +50,12 @@ export interface SessionHeader { * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Delegation depth: absent (zero) for a top-level session, parent depth + 1 + * for a subagent child. Persisted so a recursion budget survives restart and + * resume — a runtime-only depth would reset a resumed child to top-level. + */ + readonly delegationDepth?: number } /** @@ -69,6 +75,7 @@ export interface CreateSessionOptions { readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly delegationDepth?: number } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 3b15157bde..243908d27f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -882,6 +882,19 @@ describe('SessionStore', () => { }) }) + it('attaches delegationDepth from meta to the header', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('delegated-child'), { + meta: { parentSession: SessionId('parent'), delegationDepth: 2 }, + }) + expect(session.header).toMatchObject({ + id: 'delegated-child', + parentSession: 'parent', + delegationDepth: 2, + }) + }) + it('rejects non-JSON and invalid scalar session metadata', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -893,6 +906,9 @@ describe('SessionStore', () => { { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, + { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, + { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, ] for (const [index, { meta, error }] of cases.entries()) { diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 39bdecf751..3fe3b2485c 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -25,6 +25,7 @@ export interface HeaderLine { cwd?: string parentSession?: SessionId seedLength?: number + delegationDepth?: number } /** @@ -41,6 +42,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, + ...header.delegationDepth !== undefined ? { delegationDepth: header.delegationDepth } : {}, } } @@ -57,6 +59,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.cwd !== undefined ? { cwd: line.cwd } : {}, ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, + ...line.delegationDepth !== undefined ? { delegationDepth: line.delegationDepth } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4661b41309..edba26d9ae 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -253,14 +253,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, parent_session = excluded.parent_session, - seed_length = excluded.seed_length + seed_length = excluded.seed_length, + delegation_depth = excluded.delegation_depth `).run( meta.id, meta.version, @@ -268,6 +269,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.cwd ?? null, meta.parentSession ?? null, meta.seedLength ?? null, + meta.delegationDepth ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index adb23cbb43..4a7f12e759 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 4 +export const SCHEMA_VERSION = 5 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -31,6 +31,7 @@ export interface SessionRow { cwd: string | null parent_session: string | null seed_length: number | null + delegation_depth: number | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -83,9 +84,10 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy id TEXT PRIMARY KEY, version INTEGER NOT NULL, created_at INTEGER NOT NULL, - cwd TEXT, - parent_session TEXT, - seed_length INTEGER + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + delegation_depth INTEGER ) STRICT `) db.exec(` @@ -116,6 +118,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { ...row.cwd !== null ? { cwd: row.cwd } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, + ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index f26edfa54d..83d16b3315 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -385,7 +385,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(4) + expect(SCHEMA_VERSION).toBe(5) }) }) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index defcaefb6d..5ef2ce2fad 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -106,6 +106,27 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('round-trips the delegation depth through persistence', async () => { + // A subagent child's recursion budget lives in its header; a reload that + // dropped it would reset the child to top-level and un-bound maxDepth + // (JSONL stores it in the header line; SQLite uses `delegation_depth`). + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create(SessionId('delegated-child'), { + meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 }, + }) + send(session, oneTurnLog()) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child')) + expect(loaded.meta.delegationDepth).toBe(2) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index ca54dab7c0..46d4457764 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -8,7 +8,7 @@ This package is the shared run driver for the two in-process providers. Spawn pa The driver follows this sequence: -1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one. +1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`. @@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. -Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. +Depth enforcement is internal to `startInProcessRun`: it reads the parent depth via `delegationDepthOf` (the persisted `SessionHeader.delegationDepth` is authoritative; runtime `AgentOptions.subagentDepth` may deepen but never lower it, so a resumed child keeps its budget), treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. The child depth is written to the child header, so it survives persistence and resume. ## Structured output diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 6e62ee5127..f0e9731c9d 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -12,7 +12,7 @@ import type { Context } from 'cordis' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' +import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { attachStructuredRuntime, @@ -24,27 +24,6 @@ export { STRUCTURED_OUTPUT_INSTRUCTION, } from './structured.ts' -declare module '@deepseek-ai/dsh-agent' { - interface AgentOptions { - /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ - subagentDepth?: number - } -} - -/** - * Read an agent's delegation depth, treating absence as top-level depth zero. - * @param agent - the agent whose options carry the depth. - * @returns its non-negative safe-integer depth. - */ -function depthOf(agent: Agent): number { - const depth = agent.options.subagentDepth - if (depth === undefined) return 0 - if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { - throw new TypeError('agent subagentDepth must be a non-negative safe integer') - } - return depth -} - /** Thrown when starting a child would exceed the requested depth cap. */ class SubagentDepthError extends Error { constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { @@ -96,7 +75,7 @@ export async function startInProcessRun( assertSubagentMaxDepth(request.maxDepth) if (request.signal.aborted) throw prePublicationAbort() const parent = request.parent - const childDepth = depthOf(parent) + 1 + const childDepth = delegationDepthOf(parent) + 1 if (!Number.isSafeInteger(childDepth)) { throw new RangeError('subagent child depth exceeds the safe-integer range') } @@ -133,6 +112,8 @@ export async function startInProcessRun( meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, + // Durable: the recursion budget must survive persistence and resume. + delegationDepth: childDepth, ...seedLength > 0 ? { seedLength } : {}, }, ...options.seed !== undefined ? { seed: options.seed } : {}, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 13029ef3e7..9c645bc78a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -58,6 +58,44 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('persists the child depth in its session header', async () => { + const { ctx, parent } = await setup([textResponse('child answer')]) + const run = await startInProcessRun(request(parent), {}) + await run.result + // The recursion budget is durable session data, not only runtime options — + // a depth that lived only in AgentOptions would reset to 0 on resume. + expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1) + await run.dispose() + }) + + it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => { + // The review-reproduced failure chain: a depth-1 child comes back from + // persistence with a fresh AgentOptions (no subagentDepth). Its header must + // stay authoritative, or maxDepth: 1 would let it delegate as top-level. + const { ctx } = await setup([textResponse('unused')]) + const resumed = (await ctx.agents.create({ + sessionId: SessionId('resumed-child'), + meta: { parentSession: SessionId('root'), delegationDepth: 1 }, + agentOptions: { provider: 'mock', model: 'mock' }, + signal: new AbortController().signal, + })).agent + await expect(startInProcessRun({ ...request(resumed), maxDepth: 1 }, {})) + .rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 2, maxDepth: 1 }) + }) + + it('lets runtime options deepen but never lower the persisted depth', async () => { + const { ctx } = await setup([textResponse('unused')]) + const parent = (await ctx.agents.create({ + sessionId: SessionId('deep-parent'), + meta: { delegationDepth: 2 }, + agentOptions: { provider: 'mock', model: 'mock', subagentDepth: 1 }, + signal: new AbortController().signal, + })).agent + // Persisted 2 vs runtime 1: the child is depth 3, so maxDepth 2 rejects. + await expect(startInProcessRun({ ...request(parent), maxDepth: 2 }, {})) + .rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 3, maxDepth: 2 }) + }) + it('rejects invalid and exceeded depth before publication', async () => { const { parent } = await setup([]) await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) @@ -65,11 +103,11 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) .rejects.toMatchObject({ name: 'SubagentDepthError' }) for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { - const malformed = { options: { subagentDepth: value } } as unknown as Agent + const malformed = { options: { subagentDepth: value }, session: { header: {} } } as unknown as Agent await expect(startInProcessRun(request(malformed), {})) .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') } - const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent + const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER }, session: { header: {} } } as unknown as Agent await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 17edd0e3c7..bde2b61eab 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -40,6 +40,10 @@ Start-time features are advertised in `provider.capabilities` because the servic - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +## Delegation depth + +The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. + Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a live child, while `resume?` asynchronously creates a continuation run. Method presence is the capability check. `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 19779f9137..9046ae4a94 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -57,6 +57,32 @@ export type { SubagentStopReasonMap, } from './types.ts' +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** Delegation depth: zero for a top-level agent and parent depth + 1 for a child. */ + subagentDepth?: number + } +} + +/** + * Read an agent's delegation depth, treating absence as top-level depth zero. + * The persisted session header is authoritative and monotone: runtime + * `AgentOptions.subagentDepth` may DEEPEN the count but can never lower it — + * a resumed child arrives with fresh options, and counting it from zero would + * let it delegate as if it were top-level. + * @param agent - the agent whose header and options carry the depth. + * @returns its non-negative safe-integer depth. + */ +export function delegationDepthOf(agent: Agent): number { + const runtime = agent.options.subagentDepth + if (runtime !== undefined && (!Number.isSafeInteger(runtime) || runtime < 0 || Object.is(runtime, -0))) { + throw new TypeError('agent subagentDepth must be a non-negative safe integer') + } + // The header value was validated at the session boundary (creation and + // persistence load both construct through the store). + return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0) +} + /** * Reject a recursion cap that cannot represent an exact delegation depth. * @param maxDepth - the optional runtime value to validate. diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index bba6a7a5d4..a99e7339cb 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -7,7 +7,7 @@ Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L217) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L223) ### ctx.agents.currentInitiator() @@ -28,7 +28,7 @@ Read the Agent that initiated the inherited asynchronous driver chain. Use this **Returns** the inherited Agent, or `undefined` outside an initiator boundary and inside an explicit clearing boundary. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L262) ### ctx.agents.requireInitiator() @@ -48,7 +48,7 @@ Read the initiating Agent and fail when no initiator boundary is active. Use thi **Returns** the inherited Agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L269) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L275) ### ctx.agents.withInitiator(agent, operation) @@ -76,7 +76,7 @@ Run an operation with one exact Agent as its process-local initiator. The exact **Returns** the exact value returned by `operation`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L294) ### ctx.agents.withoutInitiator(operation) @@ -101,7 +101,7 @@ Run an operation inside a boundary that hides any inherited initiating Agent. Th **Returns** the exact value returned by `operation`. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L303) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L309) ### ctx.agents.setFactory(factory) @@ -127,7 +127,7 @@ Register the agent-creation factory (the loop calls this on construction, effect **Returns** the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L319) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L325) ### ctx.agents.create(options) @@ -150,7 +150,7 @@ Create and publish a new agent through the registered factory. Distinct from reg **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L352) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L358) ### ctx.agents.resume(options) @@ -171,7 +171,7 @@ Load a persisted session and resume an agent on it through the registered factor **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L371) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L377) ### ctx.agents.register(agent) @@ -203,7 +203,7 @@ Register a live agent. Throws if an agent with the same id is already registered **Returns** the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory's lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L397) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L403) ### ctx.agents.enter(agent, owner) @@ -233,7 +233,7 @@ Insert an already-constructed agent without announcing it. This is the advanced **Returns** an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L421) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L427) ### ctx.agents.announce(agent) @@ -252,7 +252,7 @@ Announce an agent previously inserted with enter. - `agent` — the live inserted agent to announce. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L496) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L502) ### ctx.agents.get(id) @@ -271,7 +271,7 @@ Look up a live agent. **Returns** the agent, or undefined when no live agent has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L530) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L536) ### ctx.agents.isOwnedBy(id, owner) @@ -294,7 +294,7 @@ Test whether a live agent was created through one exact parent agent's scoped co **Returns** true only while the exact child entry is live under that owner. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L542) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L548) ### ctx.agents.list() @@ -310,7 +310,7 @@ All live agents, in registration order. **Returns** a fresh array; mutating it does not affect the registry. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L550) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L556) ### ctx.agents.roots() @@ -328,4 +328,4 @@ All live top-level agents in registration order. A top-level agent was created w **Returns** a fresh array; mutating it does not affect the registry. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L560) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L566) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index fcfa426a81..ae1633ddaa 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -614,7 +614,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c - `info` — the run identity and terminal outcome. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L138) ### subagent/provider-added @@ -633,7 +633,7 @@ A provider became resolvable in the registry. - `provider` — the registered provider. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112) ### subagent/provider-removed @@ -652,7 +652,7 @@ A provider left the registry. Accepted runs remain holder-owned. - `name` — the provider name that no longer resolves. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L118) ### subagent/start @@ -676,7 +676,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( - `info` — the provider and ready child identity. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L129) ## system-prompt/* diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index f59001009d..73ae4385af 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L577) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L581) ### ctx.sessions.create(id?, options?) @@ -44,7 +44,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L606) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L610) ### ctx.sessions.prepare(id?, options?) @@ -75,7 +75,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L635) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L639) ### ctx.sessions.enter(session) @@ -112,7 +112,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L679) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L684) ### ctx.sessions.announce(session) @@ -131,7 +131,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L734) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L739) ### ctx.sessions.flush(session) @@ -156,7 +156,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L786) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L791) ### ctx.sessions.get(id) @@ -175,7 +175,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L818) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L823) ### ctx.sessions.list() @@ -191,7 +191,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L831) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -220,4 +220,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L843) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L848) diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md index de71a5effe..2e0d295eb3 100644 --- a/website/zh-CN/api/harness/subagents.md +++ b/website/zh-CN/api/harness/subagents.md @@ -6,7 +6,7 @@ Named provider registry and capability-checked start surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L179) ### ctx.subagents.registerProvider(provider) @@ -27,7 +27,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe; **Returns** the exact Cordis effect disposer. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L193) ### ctx.subagents.getProvider(name) @@ -46,7 +46,7 @@ Look up a provider by name. **Returns** the provider, or undefined when absent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L216) ### ctx.subagents.list() @@ -62,7 +62,7 @@ List registered provider names in insertion order. **Returns** the registered names. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L224) ### ctx.subagents.start(name, request) @@ -86,4 +86,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru **Returns** the ready holder-owned run. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L237) From cb74477d420d3457de121ef256d9f578302ac794 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 17:20:49 +0800 Subject: [PATCH 025/132] feat(tool-subagent): default maxDepth 1 with at-cap schema hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An omitted maxDepth meant unbounded recursion, and the shipped examples shipped that default. maxDepth now defaults to 1; a numeric cap requires the provider's depthLimit capability (the mount fails loud and points to the explicit 'provider-managed' opt-out for out-of-process providers), and a child AT the cap loses the delegating tool from its own schema via the child toolFilter — prompt-face hiding on top of the execution-face depth check. Examples pin maxDepth explicitly. The ACP snapshot harness gains Scenario.childToolOmissions so a child session may legitimately omit declared delegation tools from its pinned header and prompt; affected subagent/workflow goldens are re-recorded. --- docs/config-catalog.md | 15 +- examples/acp-agent/cordis.yml | 2 + examples/acp-agent/tests/acp.snapshot.ts | 13 +- .../advanced-toolchain/session.1.jsonl | 2 +- .../advanced-toolchain/session.2.jsonl | 2 +- .../snapshots/subagent-fork/session.1.jsonl | 167 +++-- .../snapshots/subagent-fork/session.jsonl | 363 +++++----- .../subagent-fork/stdout.golden.jsonl | 107 ++- .../snapshots/subagent-mixed/session.1.jsonl | 72 +- .../snapshots/subagent-mixed/session.2.jsonl | 156 +++-- .../snapshots/subagent-mixed/session.jsonl | 622 ++++++++++-------- .../subagent-mixed/stdout.golden.jsonl | 204 +++--- .../snapshots/subagent-multi/session.1.jsonl | 72 +- .../snapshots/subagent-multi/session.2.jsonl | 68 +- .../snapshots/subagent-multi/session.jsonl | 402 ++++++----- .../subagent-multi/stdout.golden.jsonl | 80 +-- .../snapshots/subagent-spawn/session.1.jsonl | 68 +- .../snapshots/subagent-spawn/session.jsonl | 287 ++++---- .../subagent-spawn/stdout.golden.jsonl | 62 +- .../snapshots/workflow-run/session.1.jsonl | 72 +- .../snapshots/workflow-run/session.jsonl | 352 ++++------ .../workflow-run/stdout.golden.jsonl | 87 +-- examples/headless-agent/cordis.yml | 2 + examples/repl-agent/cordis.yml | 2 + packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 59 +- .../tool-subagent/tests/tool-subagent.spec.ts | 136 +++- packages/support/acp-snapshot/src/suite.ts | 57 +- .../support/acp-snapshot/tests/suite.spec.ts | 32 + scripts/gen-tool-catalog.ts | 9 +- 30 files changed, 1811 insertions(+), 1763 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c3b185d13e..1cbddeb023 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1127,8 +1127,7 @@ export interface Config { /** * Tool filter applied to every child. Filtered tools disappear from its * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. Children otherwise see this tool, - * so deny it or set `maxDepth` to bound recursion. + * capability; unknown names fail startup. */ toolFilter?: { /** Global tool names the child keeps; everything else is removed. */ @@ -1137,10 +1136,16 @@ export interface Config { deny?: string[] } /** - * Maximum child depth. Requires the provider's `depthLimit` capability and a - * non-negative safe integer. Omission is unbounded. + * Maximum child depth: a non-negative safe integer (default `1`; `0` forbids + * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap + * requires the provider's `depthLimit` capability (mount fails loud + * otherwise), and a child AT the cap additionally loses this tool from its + * schema when the provider supports `toolFilter` — the prompt face of the + * budget; the service keeps rejecting on the execution face. + * `'provider-managed'` is for an out-of-process provider (ACP) whose + * recursion budget belongs to the child harness's own deployment. */ - maxDepth?: number + maxDepth?: number | 'provider-managed' } ``` diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index cb8890dae7..8e2260387d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -69,12 +69,14 @@ config: provider: spawn toolName: subagent + maxDepth: 1 - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 124117afef..c541a85e20 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -103,10 +103,12 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, + // Children sit AT the default depth cap (maxDepth 1), so each child's header + // legitimately omits the delegation tool that spawned it (schema hiding). + { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent_fork'] }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent', 'subagent_fork'] }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. @@ -121,6 +123,9 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'advanced', configPath: ADVANCED_CONFIG, + // The direct spawn child sits AT the default cap and loses `subagent`; + // workflow children bypass tool-subagent and keep the full set. + childToolOmissions: ['subagent'], }, // Prompt-submit blocks are authored keylessly: they persist a rejected turn // and hook events without starting a model step, so their logs still compare. diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 25a6f76411..26519d458e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 45d9043a4a..9daa8958dc 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 4ecb61b60f..18802ec864 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,89 +1,78 @@ -{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"/tmp/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":37} -{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352134840,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":13,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":14,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":15,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":17,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":18,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":19,"time":1783352135712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":20,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":21,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":22,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":23,"time":1783352135739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":24,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":25,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":26,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} -{"type":"assistant/chunk","seq":27,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":28,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":30,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} -{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1783352135773,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1783352135773,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":37,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":38,"time":1783352137163,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":39,"time":1783352137163,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":40,"time":1783352137163,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":41,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":43,"time":1783352137961,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":44,"time":1783352137989,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":45,"time":1783352138020,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":46,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":47,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":48,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":49,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":50,"time":1783352138074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":51,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":52,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":53,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":54,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":55,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":56,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":57,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":58,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":59,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":60,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} -{"type":"assistant/chunk","seq":61,"time":1783352138131,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} -{"type":"assistant/chunk","seq":62,"time":1783352138159,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} -{"type":"assistant/chunk","seq":63,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":64,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":65,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":66,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":67,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":68,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":69,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":70,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":71,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":72,"time":1783352138245,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":73,"time":1783352138246,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":74,"time":1783352138274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":75,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":76,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} -{"type":"assistant/chunk","seq":78,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} -{"type":"assistant/chunk","seq":79,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":80,"time":1783352138305,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} -{"type":"assistant/chunk","seq":81,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":82,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":85,"time":1783352138308,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84],"surfaceOp":"append"} -{"type":"step/end","seq":86,"time":1783352138308,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":87,"time":1783352138308,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"de67f82f-1a81-463e-8388-b323dafb8843","createdAt":1784451782049,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie","parentSession":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","seedLength":33,"delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451778261,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451778262,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451778263,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451778263,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451779947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451779948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} +{"type":"assistant/chunk","seq":13,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":15,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1784451779958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":17,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":18,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":19,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":21,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":23,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451780035,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":29,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451780041,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451780041,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451780041,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":1784451782052,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":34,"time":1784451782052,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":1784451782052,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":36,"time":1784451782052,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":37,"time":1784451783403,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":38,"time":1784451783403,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":39,"time":1784451783502,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":40,"time":1784451783534,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":41,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":42,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":43,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":44,"time":1784451783587,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":45,"time":1784451783587,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":46,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":47,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":48,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":49,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":50,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":51,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":52,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":53,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":55,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":56,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":57,"time":1784451783668,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} +{"type":"assistant/chunk","seq":58,"time":1784451783668,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":59,"time":1784451783695,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":60,"time":1784451783695,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":61,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":62,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} +{"type":"assistant/chunk","seq":63,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":64,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} +{"type":"assistant/chunk","seq":67,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} +{"type":"assistant/chunk","seq":68,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":69,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} +{"type":"assistant/chunk","seq":70,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the codeword \"MARMALADE\" earlier. They're now asking me to recall it."}}}} +{"type":"assistant/chunk","seq":71,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":72,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3331,"outputTokens":32,"cacheReadTokens":0,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":73,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":74,"time":1784451783777,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the codeword \"MARMALADE\" earlier. They're now asking me to recall it."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3331,"outputTokens":32,"cacheReadTokens":0,"reasoningTokens":27}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73],"surfaceOp":"append"} +{"type":"step/end","seq":75,"time":1784451783777,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":76,"time":1784451783777,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index 64da6e60e5..78846dd11f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,194 +1,169 @@ -{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"/tmp/acp-snap-cwd-0HLtcD"} -{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352134840,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":13,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":14,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":15,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":17,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":18,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":19,"time":1783352135712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":20,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":21,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":22,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":23,"time":1783352135739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":24,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":25,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":26,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} -{"type":"assistant/chunk","seq":27,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":28,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":30,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} -{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1783352135773,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1783352135773,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":37,"time":1783352135780,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":38,"time":1783352135780,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":39,"time":1783352135781,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":40,"time":1783352136109,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":41,"time":1783352136109,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":42,"time":1783352136226,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":43,"time":1783352136255,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":44,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":45,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":46,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":47,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":48,"time":1783352136282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":49,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":50,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":51,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":52,"time":1783352136314,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} -{"type":"assistant/chunk","seq":53,"time":1783352136314,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":54,"time":1783352136341,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} -{"type":"assistant/chunk","seq":55,"time":1783352136366,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":56,"time":1783352136367,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":57,"time":1783352136394,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":58,"time":1783352136395,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} -{"type":"assistant/chunk","seq":59,"time":1783352136395,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":60,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":61,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":62,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} -{"type":"assistant/chunk","seq":63,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} -{"type":"assistant/chunk","seq":64,"time":1783352136450,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} -{"type":"assistant/chunk","seq":65,"time":1783352136451,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":66,"time":1783352136478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":67,"time":1783352136478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":68,"time":1783352136508,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":69,"time":1783352136535,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":70,"time":1783352136535,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" able"}}} -{"type":"assistant/chunk","seq":71,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":72,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} -{"type":"assistant/chunk","seq":73,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":74,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":75,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":76,"time":1783352136591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":77,"time":1783352136591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":78,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":79,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":80,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" MAR"}}} -{"type":"assistant/chunk","seq":81,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":82,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":83,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":84,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":85,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":86,"time":1783352136648,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":87,"time":1783352136677,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":88,"time":1783352136677,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":89,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":90,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":91,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":92,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":93,"time":1783352136705,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":94,"time":1783352136706,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":95,"time":1783352136706,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} -{"type":"assistant/chunk","seq":96,"time":1783352136732,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":97,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":98,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":99,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":100,"time":1783352136819,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":101,"time":1783352136819,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":102,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":103,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":105,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783352136876,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":107,"time":1783352136877,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783352136877,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"Recall"}}} -{"type":"assistant/chunk","seq":109,"time":1783352136903,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":110,"time":1783352136903,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":111,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":112,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":113,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":114,"time":1783352136960,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":115,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":116,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":117,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":118,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":119,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":120,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":121,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"What"}}} -{"type":"assistant/chunk","seq":122,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" is"}}} -{"type":"assistant/chunk","seq":123,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":124,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":125,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":126,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":127,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":128,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" mentioned"}}} -{"type":"assistant/chunk","seq":129,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" earlier"}}} -{"type":"assistant/chunk","seq":130,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":131,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":132,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" conversation"}}} -{"type":"assistant/chunk","seq":133,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"?"}}} -{"type":"assistant/chunk","seq":134,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" Reply"}}} -{"type":"assistant/chunk","seq":135,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":136,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":137,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" that"}}} -{"type":"assistant/chunk","seq":138,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" one"}}} -{"type":"assistant/chunk","seq":139,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":140,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":141,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":142,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":143,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":144,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":145,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":146,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."}}}} -{"type":"assistant/chunk","seq":147,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":148,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}}}} -{"type":"assistant/chunk","seq":149,"time":1783352137159,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":150,"time":1783352137159,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."},{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}},"sourceEventSeqs":[40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],"surfaceOp":"append"} -{"type":"tool/call","seq":151,"time":1783352137159,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":152,"time":1783352138315,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","content":[{"type":"text","text":"MARMALADE"}],"isError":false},"sourceEventSeqs":[151],"surfaceOp":"append"} -{"type":"step/end","seq":153,"time":1783352138316,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":154,"time":1783352138317,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":155,"time":1783352138956,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":156,"time":1783352138956,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":157,"time":1783352139100,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":158,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} -{"type":"assistant/chunk","seq":159,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":160,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} -{"type":"assistant/chunk","seq":161,"time":1783352139156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correctly"}}} -{"type":"assistant/chunk","seq":162,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":163,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":164,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":165,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":166,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":167,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":168,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":169,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":170,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":171,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":172,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":173,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":174,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":175,"time":1783352139216,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":176,"time":1783352139256,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":177,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":178,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":179,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":180,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":181,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":182,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":183,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":184,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":185,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":186,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":187,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":188,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":189,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":190,"time":1783352139274,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}},"sourceEventSeqs":[155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} -{"type":"step/end","seq":191,"time":1783352139274,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":192,"time":1783352139274,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","createdAt":1784451778257,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie"} +{"type":"turn/start","seq":0,"time":1784451778261,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451778262,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451778263,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451778263,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451779947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451779948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} +{"type":"assistant/chunk","seq":13,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":15,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1784451779958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":17,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":18,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":19,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":21,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":23,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451780035,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":29,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451780041,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451780041,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451780041,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":1784451780063,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":34,"time":1784451780063,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":1784451780063,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":36,"time":1784451781196,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":37,"time":1784451781197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":38,"time":1784451781296,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":39,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":40,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":41,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":42,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":43,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":44,"time":1784451781354,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":45,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":46,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":47,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":48,"time":1784451781379,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":49,"time":1784451781408,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":50,"time":1784451781436,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":51,"time":1784451781437,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":52,"time":1784451781437,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":53,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":54,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":55,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":56,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":57,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":58,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":59,"time":1784451781490,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":60,"time":1784451781490,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":61,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":62,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":63,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":64,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" MAR"}}} +{"type":"assistant/chunk","seq":65,"time":1784451781546,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":66,"time":1784451781546,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":67,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":68,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":69,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":70,"time":1784451781572,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":71,"time":1784451781573,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} +{"type":"assistant/chunk","seq":72,"time":1784451781573,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":73,"time":1784451781598,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} +{"type":"assistant/chunk","seq":74,"time":1784451781599,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":75,"time":1784451781688,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":76,"time":1784451781688,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":77,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":78,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":80,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":82,"time":1784451781736,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":83,"time":1784451781736,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":84,"time":1784451781772,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":85,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":86,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":87,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":88,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1784451781833,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":90,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":92,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":93,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":94,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":95,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":97,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":98,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":99,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":100,"time":1784451781878,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":101,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":102,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":103,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":104,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":105,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":106,"time":1784451781906,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":107,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":108,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":109,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":110,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":111,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":112,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":113,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":114,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":115,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":116,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":117,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":118,"time":1784451781991,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":119,"time":1784451781991,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1784451782006,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":121,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to ask the child agent about the project codeword. The codeword is MARMALADE. Let me delegate this task."}}}} +{"type":"assistant/chunk","seq":122,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":123,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":201,"outputTokens":126,"cacheReadTokens":3456,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":124,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":125,"time":1784451782046,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to ask the child agent about the project codeword. The codeword is MARMALADE. Let me delegate this task."},{"type":"tool-call","id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":201,"outputTokens":126,"cacheReadTokens":3456,"reasoningTokens":38}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124],"surfaceOp":"append"} +{"type":"tool/call","seq":126,"time":1784451782047,"data":{"turn":2,"step":1,"callId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":127,"time":1784451783786,"data":{"turn":2,"step":1,"callId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","content":[{"type":"text","text":"MARMALADE"}],"isError":false},"sourceEventSeqs":[126],"surfaceOp":"append"} +{"type":"step/end","seq":128,"time":1784451783786,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":129,"time":1784451783787,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":130,"time":1784451784950,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":131,"time":1784451784950,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":132,"time":1784451785105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":133,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":134,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":135,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":136,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":137,"time":1784451785141,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":138,"time":1784451785259,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":139,"time":1784451785259,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":140,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":141,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":142,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":143,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correct"}}} +{"type":"assistant/chunk","seq":144,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":145,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":146,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":147,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":148,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":149,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":150,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":151,"time":1784451785265,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} +{"type":"assistant/chunk","seq":152,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":153,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":154,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":155,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":156,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":157,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":158,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":159,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":160,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":161,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"MARMALADE\" which is correct. Now I need to reply with PARENT_DONE."}}}} +{"type":"assistant/chunk","seq":162,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":163,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":87,"outputTokens":30,"cacheReadTokens":3712,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":164,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":165,"time":1784451785309,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"MARMALADE\" which is correct. Now I need to reply with PARENT_DONE."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":87,"outputTokens":30,"cacheReadTokens":3712,"reasoningTokens":25}},"sourceEventSeqs":[130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} +{"type":"step/end","seq":166,"time":1784451785309,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":167,"time":1784451785310,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index e2941dd851..e4f4298b50 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -6,23 +6,19 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fact"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} @@ -36,47 +32,53 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" able"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" answer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" MAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" task"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correct"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} @@ -84,33 +86,6 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correctly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index c99a5681e2..c82688afea 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc"} -{"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352145224,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352145224,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352145820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352145821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352145985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":8,"time":1783352146042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":17,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":18,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":19,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":20,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":21,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":22,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":23,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":26,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":27,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} -{"type":"assistant/chunk","seq":28,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":31,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1783352146130,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783352146130,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1783352146130,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"f117c899-e0b7-4756-baef-ca24df6c4401","createdAt":1784451789830,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","parentSession":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451789831,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451789831,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451789831,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451789832,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451796262,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451796262,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451796377,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1784451796437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1784451796437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":17,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":18,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":19,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":22,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":23,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":26,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":27,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":28,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":30,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":31,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1784451796500,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1784451796500,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1784451796500,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index adfcb6f60e..59b2ee2956 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,79 +1,77 @@ -{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":31} -{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352142835,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352143493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352143494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352143652,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":13,"time":1783352143678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":14,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":15,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":16,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":17,"time":1783352143707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":18,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":19,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":20,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} -{"type":"assistant/chunk","seq":21,"time":1783352143736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":22,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":24,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} -{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1783352143771,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} -{"type":"step/end","seq":29,"time":1783352143771,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":30,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":31,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":32,"time":1783352147509,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":33,"time":1783352147509,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":34,"time":1783352147509,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":35,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":37,"time":1783352148019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":38,"time":1783352148048,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":39,"time":1783352148049,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":40,"time":1783352148049,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":41,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":42,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} -{"type":"assistant/chunk","seq":43,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":44,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":45,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":47,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":48,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":49,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":50,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} -{"type":"assistant/chunk","seq":51,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} -{"type":"assistant/chunk","seq":52,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":53,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":54,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":55,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":56,"time":1783352148167,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":57,"time":1783352148196,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":58,"time":1783352148227,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} -{"type":"assistant/chunk","seq":59,"time":1783352148227,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":60,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":61,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":62,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":63,"time":1783352148284,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} -{"type":"assistant/chunk","seq":64,"time":1783352148285,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} -{"type":"assistant/chunk","seq":65,"time":1783352148312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} -{"type":"assistant/chunk","seq":66,"time":1783352148312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":67,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} -{"type":"assistant/chunk","seq":69,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} -{"type":"assistant/chunk","seq":70,"time":1783352148344,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} -{"type":"assistant/chunk","seq":71,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":75,"time":1783352148345,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],"surfaceOp":"append"} -{"type":"step/end","seq":76,"time":1783352148345,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":77,"time":1783352148345,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"001332d1-d501-4546-bf82-13e58b28b06b","createdAt":1784451798519,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","parentSession":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","seedLength":33,"delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451785951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451785952,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451785955,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451785955,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451787067,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451787068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451787176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451787205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} +{"type":"assistant/chunk","seq":13,"time":1784451787223,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":15,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1784451787284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":17,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":18,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":19,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":21,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1784451787313,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":23,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451787338,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":29,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451787343,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451787343,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451787343,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":1784451798520,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":34,"time":1784451798520,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":1784451798520,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":36,"time":1784451798520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":37,"time":1784451800033,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":38,"time":1784451800033,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":39,"time":1784451800128,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":40,"time":1784451800164,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":41,"time":1784451800165,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":42,"time":1784451800165,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":43,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":44,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} +{"type":"assistant/chunk","seq":45,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":46,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":47,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":48,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":49,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":50,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} +{"type":"assistant/chunk","seq":51,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":52,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":53,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":54,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":55,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":56,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":57,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":58,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":59,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":60,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":61,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} +{"type":"assistant/chunk","seq":62,"time":1784451800298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":63,"time":1784451800298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":64,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} +{"type":"assistant/chunk","seq":67,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} +{"type":"assistant/chunk","seq":68,"time":1784451800323,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} +{"type":"assistant/chunk","seq":69,"time":1784451800329,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword mentioned earlier in the conversation. The codeword is SAFFRON."}}}} +{"type":"assistant/chunk","seq":70,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":71,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2050,"outputTokens":31,"cacheReadTokens":1280,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":72,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":73,"time":1784451800330,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword mentioned earlier in the conversation. The codeword is SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2050,"outputTokens":31,"cacheReadTokens":1280,"reasoningTokens":27}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72],"surfaceOp":"append"} +{"type":"step/end","seq":74,"time":1784451800330,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":75,"time":1784451800330,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index 1ea4f541e1..ee9b134de6 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,288 +1,334 @@ -{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"/tmp/acp-snap-cwd-i43JSF"} -{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352142835,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352143493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352143494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352143652,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":13,"time":1783352143678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":14,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":15,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":16,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":17,"time":1783352143707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":18,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":19,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":20,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} -{"type":"assistant/chunk","seq":21,"time":1783352143736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":22,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":24,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} -{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1783352143771,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} -{"type":"step/end","seq":29,"time":1783352143771,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":30,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":31,"time":1783352143779,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":32,"time":1783352143779,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":33,"time":1783352143779,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":34,"time":1783352144351,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":35,"time":1783352144352,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":36,"time":1783352144477,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":37,"time":1783352144504,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":38,"time":1783352144533,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" these"}}} -{"type":"assistant/chunk","seq":39,"time":1783352144562,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":40,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} -{"type":"assistant/chunk","seq":41,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} -{"type":"assistant/chunk","seq":42,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":43,"time":1783352144591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} -{"type":"assistant/chunk","seq":44,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":45,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} -{"type":"assistant/chunk","seq":46,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":47,"time":1783352144621,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":48,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":49,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} -{"type":"assistant/chunk","seq":50,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":51,"time":1783352144678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":52,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":53,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":54,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":55,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":56,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":57,"time":1783352144707,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":58,"time":1783352144708,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":59,"time":1783352144737,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} -{"type":"assistant/chunk","seq":60,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":61,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} -{"type":"assistant/chunk","seq":62,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":63,"time":1783352144765,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":64,"time":1783352144794,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":65,"time":1783352144794,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":66,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":67,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":68,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":69,"time":1783352144824,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":70,"time":1783352144892,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":71,"time":1783352144892,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":72,"time":1783352144931,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":73,"time":1783352144932,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":74,"time":1783352144932,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":75,"time":1783352145000,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":77,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":78,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":79,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":80,"time":1783352145012,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":81,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":82,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" only"}}} -{"type":"assistant/chunk","seq":83,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":84,"time":1783352145047,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":85,"time":1783352145047,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":86,"time":1783352145073,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":87,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":88,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":89,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":90,"time":1783352145104,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":91,"time":1783352145104,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":92,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":93,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":94,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":95,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":96,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":97,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":98,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":99,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":100,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":101,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":102,"time":1783352145160,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":103,"time":1783352145161,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783352145161,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":105,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."}}}} -{"type":"assistant/chunk","seq":106,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":107,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}}}} -{"type":"assistant/chunk","seq":108,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":109,"time":1783352145221,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."},{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} -{"type":"tool/call","seq":110,"time":1783352145222,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":111,"time":1783352146133,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[110],"surfaceOp":"append"} -{"type":"step/end","seq":112,"time":1783352146134,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":113,"time":1783352146134,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":114,"time":1783352146748,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":115,"time":1783352146748,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":116,"time":1783352146837,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":117,"time":1783352146865,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":118,"time":1783352146865,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":119,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":120,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":121,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":122,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":123,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":124,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":125,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":126,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":127,"time":1783352146898,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":128,"time":1783352146898,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":129,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":130,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":131,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":132,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":133,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":134,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":135,"time":1783352146951,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":136,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":137,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} -{"type":"assistant/chunk","seq":138,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} -{"type":"assistant/chunk","seq":139,"time":1783352146979,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":140,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":141,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} -{"type":"assistant/chunk","seq":142,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} -{"type":"assistant/chunk","seq":143,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":144,"time":1783352147009,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":145,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} -{"type":"assistant/chunk","seq":146,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":147,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} -{"type":"assistant/chunk","seq":148,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} -{"type":"assistant/chunk","seq":149,"time":1783352147037,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":150,"time":1783352147037,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":151,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":152,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":153,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":154,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":155,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":156,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":157,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":158,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":159,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":160,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":161,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":162,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":163,"time":1783352147214,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"Recall"}}} -{"type":"assistant/chunk","seq":164,"time":1783352147242,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":165,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":166,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":167,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":168,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":169,"time":1783352147303,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":170,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":171,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":172,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":173,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":174,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":175,"time":1783352147330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":176,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"What"}}} -{"type":"assistant/chunk","seq":177,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" is"}}} -{"type":"assistant/chunk","seq":178,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":179,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":180,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":181,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":182,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":183,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" mentioned"}}} -{"type":"assistant/chunk","seq":184,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" earlier"}}} -{"type":"assistant/chunk","seq":185,"time":1783352147358,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":186,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":187,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" conversation"}}} -{"type":"assistant/chunk","seq":188,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"?"}}} -{"type":"assistant/chunk","seq":189,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" Reply"}}} -{"type":"assistant/chunk","seq":190,"time":1783352147386,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":191,"time":1783352147386,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":192,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" that"}}} -{"type":"assistant/chunk","seq":193,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" one"}}} -{"type":"assistant/chunk","seq":194,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":195,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":196,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":197,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":198,"time":1783352147442,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":199,"time":1783352147442,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":200,"time":1783352147443,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":201,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."}}}} -{"type":"assistant/chunk","seq":202,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":203,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}}}} -{"type":"assistant/chunk","seq":204,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":205,"time":1783352147503,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."},{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}},"sourceEventSeqs":[114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} -{"type":"tool/call","seq":206,"time":1783352147503,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":207,"time":1783352148348,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","content":[{"type":"text","text":"SAFFRON"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} -{"type":"step/end","seq":208,"time":1783352148348,"data":{"turn":2,"step":2}} -{"type":"step/start","seq":209,"time":1783352148348,"data":{"turn":2,"step":3}} -{"type":"assistant/chunk","seq":210,"time":1783352149007,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":211,"time":1783352149008,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} -{"type":"assistant/chunk","seq":212,"time":1783352149189,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":213,"time":1783352149217,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} -{"type":"assistant/chunk","seq":214,"time":1783352149217,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":215,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":216,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":217,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":218,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} -{"type":"assistant/chunk","seq":219,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":220,"time":1783352149273,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} -{"type":"assistant/chunk","seq":221,"time":1783352149274,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":222,"time":1783352149305,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} -{"type":"assistant/chunk","seq":223,"time":1783352149306,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":224,"time":1783352149330,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":225,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":226,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":227,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} -{"type":"assistant/chunk","seq":228,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":229,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":230,"time":1783352149359,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Second"}}} -{"type":"assistant/chunk","seq":231,"time":1783352149359,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":232,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} -{"type":"assistant/chunk","seq":233,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} -{"type":"assistant/chunk","seq":234,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":235,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} -{"type":"assistant/chunk","seq":236,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":237,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} -{"type":"assistant/chunk","seq":238,"time":1783352149416,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} -{"type":"assistant/chunk","seq":239,"time":1783352149417,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} -{"type":"assistant/chunk","seq":240,"time":1783352149417,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":241,"time":1783352149445,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":242,"time":1783352149445,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" correctly"}}} -{"type":"assistant/chunk","seq":243,"time":1783352149474,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" inherited"}}} -{"type":"assistant/chunk","seq":244,"time":1783352149503,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":245,"time":1783352149503,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":246,"time":1783352149536,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} -{"type":"assistant/chunk","seq":247,"time":1783352149536,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" where"}}} -{"type":"assistant/chunk","seq":248,"time":1783352149559,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":249,"time":1783352149588,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":250,"time":1783352149619,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":251,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":252,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":253,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":254,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":255,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":256,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":257,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":258,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} -{"type":"assistant/chunk","seq":259,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} -{"type":"assistant/chunk","seq":260,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} -{"type":"assistant/chunk","seq":261,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\".\n\n"}}} -{"type":"assistant/chunk","seq":262,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} -{"type":"assistant/chunk","seq":263,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":264,"time":1783352149707,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":265,"time":1783352149734,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":266,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":267,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":268,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":269,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":270,"time":1783352149762,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":271,"time":1783352149763,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":272,"time":1783352149763,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":273,"time":1783352149791,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":274,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":275,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":276,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":277,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":278,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":279,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":280,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}} -{"type":"assistant/chunk","seq":281,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":282,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}}}} -{"type":"assistant/chunk","seq":283,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":284,"time":1783352149822,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}},"sourceEventSeqs":[210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283],"surfaceOp":"append"} -{"type":"step/end","seq":285,"time":1783352149822,"data":{"turn":2,"step":3}} -{"type":"turn/end","seq":286,"time":1783352149822,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","createdAt":1784451785949,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm"} +{"type":"turn/start","seq":0,"time":1784451785951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451785952,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451785955,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451785955,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451787067,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451787068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451787176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451787205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} +{"type":"assistant/chunk","seq":13,"time":1784451787223,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":15,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1784451787284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":17,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":18,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":19,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":21,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1784451787313,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":23,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451787338,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":29,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451787343,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451787343,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451787343,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":33,"time":1784451787362,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":34,"time":1784451787362,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":35,"time":1784451787362,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":36,"time":1784451788687,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":37,"time":1784451788687,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":38,"time":1784451788784,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":39,"time":1784451788814,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":40,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":41,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":42,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":43,"time":1784451788841,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":44,"time":1784451788841,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} +{"type":"assistant/chunk","seq":45,"time":1784451788867,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} +{"type":"assistant/chunk","seq":46,"time":1784451788867,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} +{"type":"assistant/chunk","seq":47,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":48,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":49,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":50,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":51,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":52,"time":1784451788900,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":53,"time":1784451788900,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":54,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":55,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":56,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":57,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":58,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} +{"type":"assistant/chunk","seq":59,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":60,"time":1784451788953,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":61,"time":1784451788953,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":62,"time":1784451788954,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":63,"time":1784451788954,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":64,"time":1784451788977,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":65,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":66,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":67,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":68,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":69,"time":1784451789012,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":70,"time":1784451789012,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":71,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} +{"type":"assistant/chunk","seq":72,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":73,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":74,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":75,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":76,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":77,"time":1784451789060,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":78,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":79,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":80,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":81,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":82,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":83,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":84,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":85,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":86,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":87,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":88,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":89,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":90,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":91,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":92,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} +{"type":"assistant/chunk","seq":93,"time":1784451789144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} +{"type":"assistant/chunk","seq":94,"time":1784451789144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":95,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":96,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":97,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":98,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":99,"time":1784451789173,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":100,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":101,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":102,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":103,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":104,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":105,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":106,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"What"}}} +{"type":"assistant/chunk","seq":107,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":108,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":109,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":110,"time":1784451789203,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":111,"time":1784451789229,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":112,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":113,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} +{"type":"assistant/chunk","seq":114,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":115,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":116,"time":1784451789231,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":117,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":118,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?"}}} +{"type":"assistant/chunk","seq":119,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":120,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":121,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":122,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":123,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":124,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":125,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":126,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":127,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":128,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":129,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":130,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":131,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":132,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":133,"time":1784451789313,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":134,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":135,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":136,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":137,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":138,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":139,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":140,"time":1784451789364,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":141,"time":1784451789365,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":142,"time":1784451789405,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'.\n\n"}}} +{"type":"assistant/chunk","seq":143,"time":1784451789405,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":144,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":145,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":146,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":147,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} +{"type":"assistant/chunk","seq":148,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":149,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":150,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":151,"time":1784451789509,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":152,"time":1784451789509,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":153,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":154,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":155,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":156,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":157,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":158,"time":1784451789557,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1784451789557,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":160,"time":1784451789586,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":161,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":162,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":163,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" only"}}} +{"type":"assistant/chunk","seq":164,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":165,"time":1784451789641,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":166,"time":1784451789641,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":167,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":168,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":169,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":170,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":171,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":172,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":173,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":174,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":175,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":176,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":177,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":178,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":179,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":180,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":181,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":182,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":183,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":184,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":185,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":186,"time":1784451789828,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool (fresh child) with the prompt: 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool (forked child that inherits this conversation) with the prompt: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with step 1."}}}} +{"type":"assistant/chunk","seq":187,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":188,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":229,"outputTokens":189,"cacheReadTokens":3456,"reasoningTokens":114}}}} +{"type":"assistant/chunk","seq":189,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":190,"time":1784451789829,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool (fresh child) with the prompt: 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool (forked child that inherits this conversation) with the prompt: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":229,"outputTokens":189,"cacheReadTokens":3456,"reasoningTokens":114}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} +{"type":"tool/call","seq":191,"time":1784451789829,"data":{"turn":2,"step":1,"callId":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":192,"time":1784451796518,"data":{"turn":2,"step":1,"callId":"call_00_fOmtdGKlmFUAPjPULOUC7301","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[191],"surfaceOp":"append"} +{"type":"step/end","seq":193,"time":1784451796519,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":194,"time":1784451796519,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":195,"time":1784451797797,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":196,"time":1784451797797,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":197,"time":1784451797899,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":198,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":199,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":200,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":201,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":202,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":203,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":204,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":205,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":206,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":207,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":208,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":209,"time":1784451797972,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":210,"time":1784451797972,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":211,"time":1784451797997,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":212,"time":1784451798035,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":213,"time":1784451798035,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":214,"time":1784451798036,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":215,"time":1784451798036,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":216,"time":1784451798058,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":217,"time":1784451798058,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":218,"time":1784451798090,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":219,"time":1784451798090,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":220,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":221,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":222,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":223,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":224,"time":1784451798192,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":225,"time":1784451798192,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":226,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":227,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":228,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":229,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":230,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":231,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":232,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":233,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":234,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":235,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":236,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":237,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":238,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":239,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":240,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":241,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":242,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":243,"time":1784451798331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":244,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":245,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":246,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":247,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":248,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":249,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":250,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":251,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":252,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":253,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":254,"time":1784451798409,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":255,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":256,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":257,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":258,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":259,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":260,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":261,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":262,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":263,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":264,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":265,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":266,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":267,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":268,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":269,"time":1784451798476,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":270,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork to ask about the project codeword."}}}} +{"type":"assistant/chunk","seq":271,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":272,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":116,"cacheReadTokens":3840,"reasoningTokens":28}}}} +{"type":"assistant/chunk","seq":273,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":274,"time":1784451798518,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork to ask about the project codeword."},{"type":"tool-call","id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":49,"outputTokens":116,"cacheReadTokens":3840,"reasoningTokens":28}},"sourceEventSeqs":[195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273],"surfaceOp":"append"} +{"type":"tool/call","seq":275,"time":1784451798518,"data":{"turn":2,"step":2,"callId":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":276,"time":1784451800338,"data":{"turn":2,"step":2,"callId":"call_00_4obRCMnU95bJeDWflv6h9606","content":[{"type":"text","text":"SAFFRON"}],"isError":false},"sourceEventSeqs":[275],"surfaceOp":"append"} +{"type":"step/end","seq":277,"time":1784451800339,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":278,"time":1784451800340,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":279,"time":1784451801612,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":280,"time":1784451801612,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":281,"time":1784451801773,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":282,"time":1784451801804,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":283,"time":1784451801805,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":284,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":285,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":286,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":287,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":288,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":289,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":290,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":291,"time":1784451801888,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} +{"type":"assistant/chunk","seq":292,"time":1784451801889,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} +{"type":"assistant/chunk","seq":293,"time":1784451801889,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} +{"type":"assistant/chunk","seq":294,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":295,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":296,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":297,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":298,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":299,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Sub"}}} +{"type":"assistant/chunk","seq":300,"time":1784451801939,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":301,"time":1784451801939,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":302,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":303,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":304,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"inher"}}} +{"type":"assistant/chunk","seq":305,"time":1784451801993,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"iting"}}} +{"type":"assistant/chunk","seq":306,"time":1784451802026,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":307,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} +{"type":"assistant/chunk","seq":308,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} +{"type":"assistant/chunk","seq":309,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":310,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":311,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} +{"type":"assistant/chunk","seq":312,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":313,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":314,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":315,"time":1784451802086,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":316,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} +{"type":"assistant/chunk","seq":317,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":318,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":319,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":320,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":321,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":322,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":323,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":324,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":325,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":326,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First subagent (fresh): ALPHA\n2. Subagent_fork (inheriting conversation): SAFFRON\n\nNow I reply with PARENT_DONE."}}}} +{"type":"assistant/chunk","seq":327,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":328,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":46,"cacheReadTokens":3968,"reasoningTokens":41}}}} +{"type":"assistant/chunk","seq":329,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":330,"time":1784451802126,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents returned:\n1. First subagent (fresh): ALPHA\n2. Subagent_fork (inheriting conversation): SAFFRON\n\nNow I reply with PARENT_DONE."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":52,"outputTokens":46,"cacheReadTokens":3968,"reasoningTokens":41}},"sourceEventSeqs":[279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329],"surfaceOp":"append"} +{"type":"step/end","seq":331,"time":1784451802126,"data":{"turn":2,"step":3}} +{"type":"turn/end","seq":332,"time":1784451802126,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index e5cc8bfa90..95a9090889 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -7,36 +7,35 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fact"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" these"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" deleg"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ations"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sequentially"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} @@ -46,30 +45,29 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fresh"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} @@ -87,6 +85,77 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"What"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mentioned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" earlier"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"?"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'.\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" step"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_fOmtdGKlmFUAPjPULOUC7301","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_fOmtdGKlmFUAPjPULOUC7301","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ask"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} @@ -96,8 +165,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_4obRCMnU95bJeDWflv6h9606","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_4obRCMnU95bJeDWflv6h9606","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} @@ -106,61 +175,38 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fresh"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"):"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fork"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"):"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correctly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inherited"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"iting"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" context"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" where"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"):"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index 86c481c5ff..7f7fe92b80 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e"} -{"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352127671,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352127671,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352128125,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352128125,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352128240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352128281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":17,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":18,"time":1783352128301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":19,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":20,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":21,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":22,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":23,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1783352128364,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":26,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":27,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} -{"type":"assistant/chunk","seq":28,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":31,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1783352128365,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783352128365,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1783352128366,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"f425022f-47e5-46e7-84f6-c364c7e969d8","createdAt":1784451769872,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","parentSession":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451769874,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451769874,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451769874,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451769875,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451770880,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451770880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451770966,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":17,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":18,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":19,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":22,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":23,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":26,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":27,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":28,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":30,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":31,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1784451771087,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1784451771087,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1784451771087,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 483e687a14..4ece8fa0de 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,34 +1,34 @@ -{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e"} -{"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352129663,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352129663,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352130236,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352130236,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352130375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":17,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":18,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":21,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":22,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":25,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":26,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":27,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":28,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1783352130528,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1783352130528,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1783352130528,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"6f1571df-557f-45ff-b434-e13ee06d2b9f","createdAt":1784451773103,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","parentSession":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451773104,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451773104,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451773105,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451773105,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451775865,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451775865,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451775999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":17,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":18,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":25,"time":1784451776102,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":26,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":83,"outputTokens":21,"cacheReadTokens":3200,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":29,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451776109,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":83,"outputTokens":21,"cacheReadTokens":3200,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451776109,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451776109,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index a8093fba04..9649a197bd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,208 +1,194 @@ -{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"/tmp/acp-snap-cwd-28z5Of"} -{"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352126252,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352126253,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352126729,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352126729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352126848,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352126877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":11,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352126907,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":13,"time":1783352126907,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":14,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":15,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} -{"type":"assistant/chunk","seq":16,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":17,"time":1783352126909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} -{"type":"assistant/chunk","seq":18,"time":1783352126933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":19,"time":1783352126963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} -{"type":"assistant/chunk","seq":20,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} -{"type":"assistant/chunk","seq":21,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":22,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} -{"type":"assistant/chunk","seq":23,"time":1783352126993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")."}}} -{"type":"assistant/chunk","seq":24,"time":1783352126993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} -{"type":"assistant/chunk","seq":25,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":26,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":27,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":28,"time":1783352127052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":29,"time":1783352127053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":30,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":31,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":32,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":33,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":34,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} -{"type":"assistant/chunk","seq":35,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":36,"time":1783352127110,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":37,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":38,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":39,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":40,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":41,"time":1783352127172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":42,"time":1783352127197,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} -{"type":"assistant/chunk","seq":43,"time":1783352127198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} -{"type":"assistant/chunk","seq":44,"time":1783352127198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":45,"time":1783352127227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":46,"time":1783352127227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":47,"time":1783352127228,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":48,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":49,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":50,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":51,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":52,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":53,"time":1783352127258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":54,"time":1783352127343,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":55,"time":1783352127344,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":56,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":57,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":59,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":60,"time":1783352127401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":61,"time":1783352127401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1783352127402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"Return"}}} -{"type":"assistant/chunk","seq":63,"time":1783352127430,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":64,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":65,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":66,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" only"}}} -{"type":"assistant/chunk","seq":67,"time":1783352127460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":68,"time":1783352127486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":69,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":70,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":71,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":72,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":73,"time":1783352127515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":74,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":75,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":76,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":77,"time":1783352127545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":78,"time":1783352127545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":79,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":80,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":81,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":82,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":83,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":84,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":85,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":86,"time":1783352127578,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":87,"time":1783352127578,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":88,"time":1783352127605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":89,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":90,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":91,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}}}} -{"type":"assistant/chunk","seq":92,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":93,"time":1783352127668,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."},{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} -{"type":"tool/call","seq":94,"time":1783352127668,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":95,"time":1783352128371,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[94],"surfaceOp":"append"} -{"type":"step/end","seq":96,"time":1783352128371,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":97,"time":1783352128372,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":98,"time":1783352129034,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":99,"time":1783352129034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} -{"type":"assistant/chunk","seq":100,"time":1783352129152,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":101,"time":1783352129166,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":102,"time":1783352129167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":103,"time":1783352129167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":104,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":105,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":106,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":107,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":108,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":109,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":110,"time":1783352129224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":111,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} -{"type":"assistant/chunk","seq":112,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":113,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":114,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":115,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":116,"time":1783352129255,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":117,"time":1783352129282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} -{"type":"assistant/chunk","seq":118,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":119,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":120,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":121,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":122,"time":1783352129371,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":123,"time":1783352129371,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":124,"time":1783352129399,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":125,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":127,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":128,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":129,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":130,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"Return"}}} -{"type":"assistant/chunk","seq":131,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":132,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"ETA"}}} -{"type":"assistant/chunk","seq":133,"time":1783352129457,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" only"}}} -{"type":"assistant/chunk","seq":134,"time":1783352129457,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":135,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":136,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":137,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":138,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":139,"time":1783352129515,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":140,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":141,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":142,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":143,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":144,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":145,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":146,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":147,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":148,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"ETA"}}} -{"type":"assistant/chunk","seq":149,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":150,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":151,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":152,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":153,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":154,"time":1783352129603,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":155,"time":1783352129660,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}}} -{"type":"assistant/chunk","seq":156,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":157,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":158,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":159,"time":1783352129661,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."},{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}},"sourceEventSeqs":[98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} -{"type":"tool/call","seq":160,"time":1783352129661,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} -{"type":"tool/result","seq":161,"time":1783352130531,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","content":[{"type":"text","text":"BETA"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} -{"type":"step/end","seq":162,"time":1783352130531,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":163,"time":1783352130532,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":164,"time":1783352130930,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":165,"time":1783352130930,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} -{"type":"assistant/chunk","seq":166,"time":1783352131045,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":167,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} -{"type":"assistant/chunk","seq":168,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} -{"type":"assistant/chunk","seq":169,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":170,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":171,"time":1783352131096,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":172,"time":1783352131097,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":173,"time":1783352131128,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":174,"time":1783352131128,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":175,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":176,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":177,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} -{"type":"assistant/chunk","seq":178,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":179,"time":1783352131157,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":180,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":181,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":182,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":183,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":184,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":185,"time":1783352131185,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":186,"time":1783352131185,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":187,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":188,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":189,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":190,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":191,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":192,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":193,"time":1783352131214,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":194,"time":1783352131214,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":195,"time":1783352131241,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":196,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":197,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":198,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":199,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":200,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":201,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":202,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":203,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":204,"time":1783352131243,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203],"surfaceOp":"append"} -{"type":"step/end","seq":205,"time":1783352131243,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":206,"time":1783352131243,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","createdAt":1784451767994,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm"} +{"type":"turn/start","seq":0,"time":1784451767996,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451767996,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451768000,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451768000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451769159,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451769159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451769269,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451769299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451769300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1784451769327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":13,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":14,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":15,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":16,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":17,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} +{"type":"assistant/chunk","seq":18,"time":1784451769350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":19,"time":1784451769381,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":20,"time":1784451769408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":21,"time":1784451769408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":22,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":23,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":24,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1784451769464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":26,"time":1784451769464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompts"}}} +{"type":"assistant/chunk","seq":27,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":28,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":29,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":30,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":31,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":33,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":34,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":35,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":36,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":37,"time":1784451769563,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1784451769563,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":39,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":40,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":42,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":46,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":47,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":48,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" -"}}} +{"type":"assistant/chunk","seq":49,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":50,"time":1784451769670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":51,"time":1784451769671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":52,"time":1784451769671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":54,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":56,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":57,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":59,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":61,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":62,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":63,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":64,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":65,"time":1784451769763,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":66,"time":1784451769782,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":67,"time":1784451769782,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":68,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":69,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":70,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":71,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":72,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":74,"time":1784451769866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time), with specific prompts. Let me start with the first subtask."}}}} +{"type":"assistant/chunk","seq":75,"time":1784451769866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":76,"time":1784451769867,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3621,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":77,"time":1784451769867,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":78,"time":1784451769870,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time), with specific prompts. Let me start with the first subtask."},{"type":"tool-call","id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3621,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":32}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"surfaceOp":"append"} +{"type":"tool/call","seq":79,"time":1784451769871,"data":{"turn":1,"step":1,"callId":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":80,"time":1784451771107,"data":{"turn":1,"step":1,"callId":"call_00_pNGPLxkadUA9vn2Bm42x5565","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"step/end","seq":81,"time":1784451771108,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":82,"time":1784451771108,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":83,"time":1784451772258,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":84,"time":1784451772258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":85,"time":1784451772383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":86,"time":1784451772412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":87,"time":1784451772439,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":88,"time":1784451772440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":89,"time":1784451772440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":90,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":91,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":92,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":93,"time":1784451772491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":94,"time":1784451772491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":95,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":96,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":97,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":98,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":99,"time":1784451772494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":100,"time":1784451772494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":101,"time":1784451772495,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":102,"time":1784451772517,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":103,"time":1784451772517,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":104,"time":1784451772600,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":105,"time":1784451772600,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":106,"time":1784451772634,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":107,"time":1784451772634,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":108,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":109,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":110,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":111,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":112,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":113,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":114,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":115,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" -"}}} +{"type":"assistant/chunk","seq":116,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":117,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":118,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":119,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":122,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":123,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":124,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":125,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":127,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":128,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":129,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":130,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":131,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":132,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":133,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":134,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":135,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":136,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":137,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":139,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to run the second subtask."}}}} +{"type":"assistant/chunk","seq":140,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":141,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":95,"cacheReadTokens":3712,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":142,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":143,"time":1784451773102,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to run the second subtask."},{"type":"tool-call","id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":95,"cacheReadTokens":3712,"reasoningTokens":20}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} +{"type":"tool/call","seq":144,"time":1784451773102,"data":{"turn":1,"step":2,"callId":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} +{"type":"tool/result","seq":145,"time":1784451776122,"data":{"turn":1,"step":2,"callId":"call_00_xg61TI40vMlqTuVIpJWj8575","content":[{"type":"text","text":"BETA"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} +{"type":"step/end","seq":146,"time":1784451776122,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":147,"time":1784451776122,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":148,"time":1784451777273,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":149,"time":1784451777273,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":150,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":151,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":152,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":153,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":154,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":155,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":156,"time":1784451777407,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":157,"time":1784451777407,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":158,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":159,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":160,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":161,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":162,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":163,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":164,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":165,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":166,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":167,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":168,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":169,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":170,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":171,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":172,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":173,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":174,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":175,"time":1784451777515,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":176,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":177,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":178,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":179,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":180,"time":1784451777547,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":181,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":182,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":183,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":184,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":185,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":186,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. First returned \"ALPHA\", second returned \"BETA\". Now I need to reply with exactly \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":187,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":188,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":14,"outputTokens":37,"cacheReadTokens":3840,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":189,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":190,"time":1784451777586,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. First returned \"ALPHA\", second returned \"BETA\". Now I need to reply with exactly \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":14,"outputTokens":37,"cacheReadTokens":3840,"reasoningTokens":32}},"sourceEventSeqs":[148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} +{"type":"step/end","seq":191,"time":1784451777586,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":192,"time":1784451777586,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index bd4fb81d4a..487692bb46 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -18,40 +18,24 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"),"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompts"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_pNGPLxkadUA9vn2Bm42x5565","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"First subtask - ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_pNGPLxkadUA9vn2Bm42x5565","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} @@ -62,44 +46,42 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return BETA only","prompt":"Reply with exactly the word BETA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xg61TI40vMlqTuVIpJWj8575","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Second subtask - BETA","prompt":"Reply with exactly the word BETA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_xg61TI40vMlqTuVIpJWj8575","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 8dd26c4e70..5afa333183 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,34 +1,34 @@ -{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"/tmp/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17"} -{"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352120856,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352120856,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352121437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352121438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352121635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} -{"type":"assistant/chunk","seq":16,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":17,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":18,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":19,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":20,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":21,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} -{"type":"assistant/chunk","seq":24,"time":1783352121748,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} -{"type":"assistant/chunk","seq":25,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":26,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} -{"type":"assistant/chunk","seq":27,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":29,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1783352121777,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1783352121778,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1783352121778,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"aefc3a97-9b46-42d3-993c-7f7c19f9e327","createdAt":1784451764214,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C","parentSession":"5ab41657-0a0f-4317-88fe-451c5197cdb4","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451764215,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451764216,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451764216,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451764216,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451765645,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451765645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451765790,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1784451765826,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1784451765827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":15,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":16,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":17,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":20,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":21,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} +{"type":"assistant/chunk","seq":24,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":25,"time":1784451765887,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784451765891,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":27,"time":1784451765891,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1784451765892,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3286,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":29,"time":1784451765892,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1784451765892,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"CHILD_OK\" and nothing else."},{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3286,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1784451765893,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1784451765893,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index 6a87cfabb5..5dddaa522f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,160 +1,127 @@ -{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"/tmp/acp-snap-cwd-rbeWyt"} -{"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352119275,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352119281,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352119925,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352119925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352120053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352120080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352120081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352120081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352120111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":11,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":12,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":13,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}} -{"type":"assistant/chunk","seq":14,"time":1783352120113,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":15,"time":1783352120136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":16,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":17,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":18,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":19,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":20,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":21,"time":1783352120164,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} -{"type":"assistant/chunk","seq":22,"time":1783352120164,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":23,"time":1783352120192,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} -{"type":"assistant/chunk","seq":24,"time":1783352120192,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":25,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":26,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} -{"type":"assistant/chunk","seq":27,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":28,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":29,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":30,"time":1783352120222,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":31,"time":1783352120248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} -{"type":"assistant/chunk","seq":32,"time":1783352120248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":33,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":34,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":35,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":36,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":37,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n"}}} -{"type":"assistant/chunk","seq":38,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":39,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":40,"time":1783352120278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":41,"time":1783352120278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":42,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":43,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":44,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":45,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":46,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":47,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":48,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":49,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":50,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":51,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} -{"type":"assistant/chunk","seq":52,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":53,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":54,"time":1783352120361,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":55,"time":1783352120362,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":56,"time":1783352120362,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":57,"time":1783352120394,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n"}}} -{"type":"assistant/chunk","seq":58,"time":1783352120395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":59,"time":1783352120395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":60,"time":1783352120396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Do"}}} -{"type":"assistant/chunk","seq":61,"time":1783352120396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":62,"time":1783352120397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":63,"time":1783352120397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":64,"time":1783352120421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":65,"time":1783352120421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":66,"time":1783352120449,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":67,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":68,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":69,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":70,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":71,"time":1783352120476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":72,"time":1783352120532,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":73,"time":1783352120532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":74,"time":1783352120559,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":75,"time":1783352120560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1783352120560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":77,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":78,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":79,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":80,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":81,"time":1783352120617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":82,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" CH"}}} -{"type":"assistant/chunk","seq":83,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":84,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":85,"time":1783352120643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":86,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":87,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":88,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":89,"time":1783352120672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":90,"time":1783352120672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":91,"time":1783352120700,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":92,"time":1783352120702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":93,"time":1783352120702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":94,"time":1783352120703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":95,"time":1783352120728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":96,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":97,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":98,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" CH"}}} -{"type":"assistant/chunk","seq":99,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":100,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":101,"time":1783352120765,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":102,"time":1783352120765,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":103,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":104,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":105,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783352120784,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":107,"time":1783352120851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}}} -{"type":"assistant/chunk","seq":108,"time":1783352120851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":109,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}}} -{"type":"assistant/chunk","seq":110,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":111,"time":1783352120854,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."},{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} -{"type":"tool/call","seq":112,"time":1783352120854,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":113,"time":1783352121784,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","content":[{"type":"text","text":"CHILD_OK"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1783352121784,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":115,"time":1783352121785,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":116,"time":1783352122364,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":117,"time":1783352122364,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":118,"time":1783352122524,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":119,"time":1783352122552,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":120,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":121,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":122,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} -{"type":"assistant/chunk","seq":123,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":124,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":125,"time":1783352122581,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783352122582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":127,"time":1783352122582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} -{"type":"assistant/chunk","seq":128,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":129,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":130,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":131,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":132,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":133,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":134,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":135,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":136,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":137,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":138,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":139,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":140,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":141,"time":1783352122675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":142,"time":1783352122675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":143,"time":1783352122701,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":144,"time":1783352122701,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":145,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":146,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":147,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":148,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":149,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":150,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":151,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":152,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":153,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":154,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":155,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":156,"time":1783352122732,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} -{"type":"step/end","seq":157,"time":1783352122732,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":158,"time":1783352122732,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"5ab41657-0a0f-4317-88fe-451c5197cdb4","createdAt":1784451761926,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C"} +{"type":"turn/start","seq":0,"time":1784451761930,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451761930,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451761932,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451761932,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451763352,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451763353,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451763485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":13,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":14,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":15,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":16,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":17,"time":1784451763543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":18,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} +{"type":"assistant/chunk","seq":19,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":20,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":21,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} +{"type":"assistant/chunk","seq":22,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":23,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":24,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":25,"time":1784451763618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":26,"time":1784451763649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":27,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":28,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":29,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":31,"time":1784451763688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":32,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":33,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":34,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":35,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":36,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":37,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":38,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":39,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":40,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1784451763779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":1784451763779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":43,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":44,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":46,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":48,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":50,"time":1784451763863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":51,"time":1784451763864,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":52,"time":1784451763864,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":53,"time":1784451763885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":54,"time":1784451763885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" only"}}} +{"type":"assistant/chunk","seq":55,"time":1784451763911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":57,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":59,"time":1784451763940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":60,"time":1784451763940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":62,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":64,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":65,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":66,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":67,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":68,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":69,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":70,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":71,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":72,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":73,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":74,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":75,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1784451764043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":77,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool exactly once to delegate a specific task, then after it returns, reply with \"PARENT_DONE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":78,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":79,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3593,"outputTokens":112,"cacheReadTokens":0,"reasoningTokens":36}}}} +{"type":"assistant/chunk","seq":80,"time":1784451764212,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":81,"time":1784451764213,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool exactly once to delegate a specific task, then after it returns, reply with \"PARENT_DONE\". Let me do that."},{"type":"tool-call","id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3593,"outputTokens":112,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"} +{"type":"tool/call","seq":82,"time":1784451764214,"data":{"turn":1,"step":1,"callId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":83,"time":1784451765917,"data":{"turn":1,"step":1,"callId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","content":[{"type":"text","text":"CHILD_OK"}],"isError":false},"sourceEventSeqs":[82],"surfaceOp":"append"} +{"type":"step/end","seq":84,"time":1784451765918,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":85,"time":1784451765918,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":86,"time":1784451767064,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":87,"time":1784451767064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":88,"time":1784451767169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":89,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":90,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":91,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":92,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":93,"time":1784451767202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":94,"time":1784451767202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":95,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":96,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":97,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} +{"type":"assistant/chunk","seq":98,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":99,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":100,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":101,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":102,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":103,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":104,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":105,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":106,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":107,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":108,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":109,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":110,"time":1784451767312,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":112,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":113,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":114,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":115,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":116,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":117,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":118,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":120,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":121,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":136,"outputTokens":32,"cacheReadTokens":3584,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":122,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":123,"time":1784451767351,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":136,"outputTokens":32,"cacheReadTokens":3584,"reasoningTokens":27}},"sourceEventSeqs":[86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} +{"type":"step/end","seq":124,"time":1784451767351,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":125,"time":1784451767351,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 2b77e856e6..1228921ca4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -5,10 +5,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} @@ -17,58 +14,30 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" task"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CH"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK only","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} @@ -87,9 +56,6 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 3d89428bbd..b393644c5b 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8"} -{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783600638173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":15,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":16,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":17,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":18,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":21,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":22,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} -{"type":"assistant/chunk","seq":25,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} -{"type":"assistant/chunk","seq":26,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} -{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"2903e21a-fb6b-4d36-9a78-b9240419c334","createdAt":1784451805556,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5","parentSession":"6789922c-5a8c-4141-8336-0f9b0809bb17","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784451805557,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451805557,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451805557,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451805557,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451807175,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451807175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451807383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1784451807459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1784451807459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":15,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":16,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":25,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":26,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":30,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3550,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":31,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1784451807505,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3550,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1784451807505,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1784451807506,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 3e0ae3da73..55b622307d 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,209 +1,143 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz"} -{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":11,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":13,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":14,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":15,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":16,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":17,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":18,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} -{"type":"assistant/chunk","seq":19,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":21,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":22,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} -{"type":"assistant/chunk","seq":23,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} -{"type":"assistant/chunk","seq":24,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":25,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} -{"type":"assistant/chunk","seq":26,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":27,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":28,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":29,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} -{"type":"assistant/chunk","seq":30,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} -{"type":"assistant/chunk","seq":31,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":32,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"so"}}} -{"type":"assistant/chunk","seq":33,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":34,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} -{"type":"assistant/chunk","seq":35,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} -{"type":"assistant/chunk","seq":36,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} -{"type":"assistant/chunk","seq":37,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":38,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} -{"type":"assistant/chunk","seq":39,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":40,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":41,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meta"}}} -{"type":"assistant/chunk","seq":42,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} -{"type":"assistant/chunk","seq":43,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} -{"type":"assistant/chunk","seq":44,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":45,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"name"}}} -{"type":"assistant/chunk","seq":46,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} -{"type":"assistant/chunk","seq":47,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":48,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sn"}}} -{"type":"assistant/chunk","seq":49,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"apshot"}}} -{"type":"assistant/chunk","seq":50,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-flow"}}} -{"type":"assistant/chunk","seq":51,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} -{"type":"assistant/chunk","seq":52,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":53,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} -{"type":"assistant/chunk","seq":54,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} -{"type":"assistant/chunk","seq":55,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":56,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} -{"type":"assistant/chunk","seq":57,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":58,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} -{"type":"assistant/chunk","seq":59,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":60,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} -{"type":"assistant/chunk","seq":61,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" }\n"}}} -{"type":"assistant/chunk","seq":63,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":64,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":65,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} -{"type":"assistant/chunk","seq":66,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} -{"type":"assistant/chunk","seq":67,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":68,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} -{"type":"assistant/chunk","seq":69,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":70,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":71,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":72,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} -{"type":"assistant/chunk","seq":73,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":74,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":75,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":76,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":77,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":78,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":79,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":80,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":81,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} -{"type":"assistant/chunk","seq":82,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} -{"type":"assistant/chunk","seq":83,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} -{"type":"assistant/chunk","seq":84,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":85,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":86,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":87,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":88,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":89,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":90,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":91,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":92,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":94,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":95,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":97,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"meta"}}} -{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":99,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":100,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{\""}}} -{"type":"assistant/chunk","seq":101,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"name"}}} -{"type":"assistant/chunk","seq":102,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":103,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":104,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"sn"}}} -{"type":"assistant/chunk","seq":105,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"apshot"}}} -{"type":"assistant/chunk","seq":106,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"-flow"}}} -{"type":"assistant/chunk","seq":107,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\","}}} -{"type":"assistant/chunk","seq":108,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":109,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":110,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":111,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":112,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"one"}}} -{"type":"assistant/chunk","seq":113,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" child"}}} -{"type":"assistant/chunk","seq":114,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" for"}}} -{"type":"assistant/chunk","seq":115,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":116,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" snapshot"}}} -{"type":"assistant/chunk","seq":117,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":118,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":119,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":120,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":121,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"script"}}} -{"type":"assistant/chunk","seq":122,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":123,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":124,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":125,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\\n"}}} -{"type":"assistant/chunk","seq":126,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"phase"}}} -{"type":"assistant/chunk","seq":127,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":128,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":129,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"')\\n"}}} -{"type":"assistant/chunk","seq":130,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":131,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":132,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":133,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":134,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" agent"}}} -{"type":"assistant/chunk","seq":135,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":136,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":137,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":138,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":139,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":140,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":141,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" WF"}}} -{"type":"assistant/chunk","seq":142,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_CH"}}} -{"type":"assistant/chunk","seq":143,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":144,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":145,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":146,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":147,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":148,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":".')\\n"}}} -{"type":"assistant/chunk","seq":149,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":150,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" {"}}} -{"type":"assistant/chunk","seq":151,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":152,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" }\\n"}}} -{"type":"assistant/chunk","seq":153,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":154,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} -{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} -{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} -{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} -{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} -{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} -{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":165,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":166,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":167,"time":1783600640162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":168,"time":1783600640195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":169,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":170,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":171,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":172,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":174,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":175,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":176,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":177,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":178,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":179,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":180,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":181,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":182,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":183,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":184,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":185,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":186,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} -{"type":"assistant/chunk","seq":187,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} -{"type":"assistant/chunk","seq":188,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} -{"type":"assistant/chunk","seq":189,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":190,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":191,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":192,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":193,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":194,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":196,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} -{"type":"assistant/chunk","seq":197,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} -{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} -{"type":"assistant/chunk","seq":199,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} -{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"6789922c-5a8c-4141-8336-0f9b0809bb17","createdAt":1784451802866,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5"} +{"type":"turn/start","seq":0,"time":1784451802869,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784451802870,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784451802872,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784451802873,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784451804371,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784451804371,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784451804483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784451804504,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784451804505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784451804505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784451804506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1784451804537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1784451804537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":13,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":14,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":15,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":17,"time":1784451804588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":18,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":19,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":20,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":21,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":22,"time":1784451804613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":23,"time":1784451804613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":24,"time":1784451804640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":25,"time":1784451804641,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":26,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":27,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":28,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":29,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1784451804746,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"meta"}}} +{"type":"assistant/chunk","seq":31,"time":1784451804778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":32,"time":1784451804778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":33,"time":1784451804779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":34,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"name"}}} +{"type":"assistant/chunk","seq":35,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":36,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":37,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":38,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":39,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":40,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":41,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":42,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":44,"time":1784451804834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1784451804834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":46,"time":1784451804858,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":47,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":48,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":50,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1784451804882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":52,"time":1784451804923,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":53,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":55,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":57,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":59,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":60,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":61,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":62,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":63,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":64,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":65,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":66,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":67,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":68,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":69,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":70,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":71,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":72,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":73,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":74,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":75,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":76,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":77,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":78,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":79,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":80,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":81,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":82,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":83,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":84,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" }"}}} +{"type":"assistant/chunk","seq":85,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":87,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific workflow script with specific parameters. Let me do exactly as instructed."}}}} +{"type":"assistant/chunk","seq":88,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}}} +{"type":"assistant/chunk","seq":89,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3635,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":90,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":91,"time":1784451805459,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific workflow script with specific parameters. Let me do exactly as instructed."},{"type":"tool-call","id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3635,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":21}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} +{"type":"tool/call","seq":92,"time":1784451805460,"data":{"turn":1,"step":1,"callId":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}} +{"type":"tool/result","seq":93,"time":1784451807534,"data":{"turn":1,"step":1,"callId":"call_00_dQ54AjERB7zcWCD6mkku2211","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[92],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1784451807534,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":95,"time":1784451807535,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":96,"time":1784451809061,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":97,"time":1784451809061,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":98,"time":1784451809087,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":99,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":100,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":101,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":102,"time":1784451809146,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":103,"time":1784451809146,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":104,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":105,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":106,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":107,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":108,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":109,"time":1784451809173,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":110,"time":1784451809203,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":111,"time":1784451809203,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":112,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":113,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":114,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":115,"time":1784451809222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":116,"time":1784451809222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":117,"time":1784451809381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":118,"time":1784451809381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":119,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":120,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":121,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":122,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":123,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":124,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":125,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":127,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":128,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":130,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":131,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":132,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":133,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":134,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":135,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":136,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":137,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":210,"outputTokens":38,"cacheReadTokens":3584,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":138,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":139,"time":1784451809384,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":210,"outputTokens":38,"cacheReadTokens":3584,"reasoningTokens":32}},"sourceEventSeqs":[96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138],"surfaceOp":"append"} +{"type":"step/end","seq":140,"time":1784451809384,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":141,"time":1784451809384,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 03f482bcc6..fb2820212c 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -5,91 +5,24 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" parameters"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" args"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" omitted"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"so"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" don"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" include"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" meta"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"name"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sn"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"apshot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-flow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"one"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" snapshot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" }\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" given"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\n"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dQ54AjERB7zcWCD6mkku2211","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"phase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dQ54AjERB7zcWCD6mkku2211","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} @@ -109,7 +42,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..1cd161eea7 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -64,12 +64,14 @@ config: provider: spawn toolName: subagent + maxDepth: 1 - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's # `agent()` calls out through the spawn backend. diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index 00a6b4b9a6..1b47629249 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -77,12 +77,14 @@ config: provider: spawn toolName: subagent + maxDepth: 1 - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: provider: fork toolName: subagent_fork + maxDepth: 1 # The worker-thread workflow engine fans a model-written JavaScript script's diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 825e252923..932b410abc 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -22,7 +22,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before | `agentOptions` | Default child options, currently including `model`. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | -| `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. | +| `maxDepth` | Absolute delegation-depth cap, default `1` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap — for an out-of-process provider whose budget belongs to the child harness. A child AT the cap also loses this tool from its schema when the provider supports `toolFilter` (prompt-face hiding; the service still rejects on the execution face). | ## Concurrency diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f46f3dda2c..3cde5d6a1a 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,7 +12,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' +import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' @@ -45,8 +45,7 @@ export interface Config { /** * Tool filter applied to every child. Filtered tools disappear from its * prompt and reject execution. Requires the provider's `toolFilter` - * capability; unknown names fail startup. Children otherwise see this tool, - * so deny it or set `maxDepth` to bound recursion. + * capability; unknown names fail startup. */ toolFilter?: { /** Global tool names the child keeps; everything else is removed. */ @@ -55,10 +54,16 @@ export interface Config { deny?: string[] } /** - * Maximum child depth. Requires the provider's `depthLimit` capability and a - * non-negative safe integer. Omission is unbounded. + * Maximum child depth: a non-negative safe integer (default `1`; `0` forbids + * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap + * requires the provider's `depthLimit` capability (mount fails loud + * otherwise), and a child AT the cap additionally loses this tool from its + * schema when the provider supports `toolFilter` — the prompt face of the + * budget; the service keeps rejecting on the execution face. + * `'provider-managed'` is for an out-of-process provider (ACP) whose + * recursion budget belongs to the child harness's own deployment. */ - maxDepth?: number + maxDepth?: number | 'provider-managed' } export const Config: z = z.object({ @@ -76,7 +81,7 @@ export const Config: z = z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), }).default(undefined as unknown as { allow: string[]; deny: string[] }), - maxDepth: z.natural().max(Number.MAX_SAFE_INTEGER), + maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(1), }) /** @@ -194,15 +199,29 @@ function providerWording(inheritsConversation: boolean): { description: string; } } -function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest { +function startRequest( + config: Config, + prompt: string, + parent: Agent, + signal: AbortSignal, + hideAtCapToolName: string | undefined, +): SubagentStartRequest { + const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined + // A child AT the cap cannot delegate further: deny it this tool so its + // schema hides what the service would reject anyway (prompt face; the + // depth check at start remains the execution face). + const childAtCap = maxDepth !== undefined && delegationDepthOf(parent) + 1 >= maxDepth + const toolFilter = childAtCap && hideAtCapToolName !== undefined + ? { ...config.toolFilter, deny: [...config.toolFilter?.deny ?? [], hideAtCapToolName] } + : config.toolFilter return { prompt: [{ type: 'text', text: prompt }], parent, signal, ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, - ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {}, + ...toolFilter !== undefined ? { toolFilter } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, } } @@ -218,8 +237,9 @@ async function settleStart(start: Promise, signal: AbortSignal): Pr } export function apply(ctx: Context, config: Config): void { - // Direct apply() bypasses Schemastery's numeric constraints. - assertSubagentMaxDepth(config.maxDepth) + // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply + // omission stays capless (the schema default only runs through the loader). + if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth) // Reject an empty explicit filter at load instead of failing every delegation. if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') @@ -228,6 +248,18 @@ export function apply(ctx: Context, config: Config): void { // can change provider availability while this fiber remains active. let disposeTool: (() => void) | undefined const mount = (provider: SubagentProvider): void => { + // A numeric cap the provider cannot enforce is a misconfiguration — fail at + // mount (the earliest point the provider's capabilities are known), not on + // the first delegation. + if (typeof config.maxDepth === 'number' && !provider.capabilities.depthLimit) { + throw new Error( + `tool-subagent: provider "${provider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider', + ) + } + // Schema hiding rides the child toolFilter, so it needs that capability; + // without it the depth check at start remains the only fence. + const hideAtCapToolName = provider.capabilities.toolFilter ? config.toolName ?? 'subagent' : undefined const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false disposeTool = ctx.tools.register(defineTool({ @@ -282,7 +314,7 @@ export function apply(ctx: Context, config: Config): void { const controller = new AbortController() const start = ctx.subagents.start( config.provider, - startRequest(config, args.prompt, parent, controller.signal), + startRequest(config, args.prompt, parent, controller.signal, hideAtCapToolName), ) return { cancel: (reason?: string) => { @@ -301,6 +333,7 @@ export function apply(ctx: Context, config: Config): void { args.prompt, parent, exec.signal ?? new AbortController().signal, + hideAtCapToolName, ) const run: SubagentRun = await ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index d88367ced5..f827d6d2ba 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -7,6 +7,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as mock from './scripted-provider.ts' @@ -22,9 +23,13 @@ import { SessionId } from '@deepseek-ai/dsh-session' * shipping code path. */ -/** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ -function fakeAgent(id = 'parent-1'): Agent { - return { id: SessionId(id) } as unknown as Agent +/** A minimal parent Agent: the tool reads `agent.id` plus the delegation depth off its header/options. */ +function fakeAgent(id = 'parent-1', delegationDepth?: number): Agent { + return { + id: SessionId(id), + options: {}, + session: { header: { ...delegationDepth === undefined ? {} : { delegationDepth } } }, + } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -85,7 +90,7 @@ describe('dsh-tool-subagent', () => { // Schema omission is advertising, not enforcement: the arg validator // allows undeclared keys, so the opt-out must also hold in execute(). const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) - const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent + const parent = { id: SessionId('sess-off'), inject: () => {}, options: {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent }) expect(forced.isError).toBe(true) @@ -162,7 +167,7 @@ describe('dsh-tool-subagent', () => { dispose: async () => {}, }), }) - await ctx.plugin(tool, { provider: 'weird' }) + await ctx.plugin(tool, { provider: 'weird', maxDepth: 'provider-managed' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) @@ -191,7 +196,7 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' } }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' }, maxDepth: 'provider-managed' }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(seen?.agentOptions).toEqual({ model: 'child-model' }) @@ -348,7 +353,7 @@ describe('dsh-tool-subagent', () => { dispose: async () => void disposed(), }), }) - await ctx.plugin(tool, { provider: 'spy' }) + await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(disposed).toHaveBeenCalledTimes(1) @@ -371,7 +376,7 @@ describe('dsh-tool-subagent', () => { dispose: async () => void disposed(), }), }) - await ctx.plugin(tool, { provider: 'spy' }) + await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) @@ -404,7 +409,7 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'spy' }) + await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' }) const controller = new AbortController() const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) @@ -432,7 +437,7 @@ describe('dsh-tool-subagent', () => { throw new Error('start aborted') }, }) - await ctx.plugin(tool, { provider: 'spy' }) + await ctx.plugin(tool, { provider: 'spy', maxDepth: 'provider-managed' }) const controller = new AbortController() controller.abort() // already aborted BEFORE the tool runs @@ -511,7 +516,6 @@ describe('dsh-tool-subagent', () => { }) it.each([ - { label: 'null', value: null as unknown as number }, { label: 'a string', value: '1' as unknown as number }, { label: 'NaN', value: Number.NaN }, { label: 'positive infinity', value: Number.POSITIVE_INFINITY }, @@ -555,7 +559,7 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] } }) + await ctx.plugin(tool, { provider: 'capture3', toolFilter: { deny: ['subagent'] }, maxDepth: 'provider-managed' }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(seen?.toolFilter).toEqual({ deny: ['subagent'] }) expect(seen?.toolFilter).not.toHaveProperty('allow') @@ -585,7 +589,7 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture4' }) + await ctx.plugin(tool, { provider: 'capture4', maxDepth: 'provider-managed' }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(seen).toBeDefined() expect(seen).not.toHaveProperty('agentOptions') @@ -616,6 +620,7 @@ describe('dsh-tool-subagent background mode', () => { id, ctx: scopeFiber.ctx, inject, + options: {}, session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(agent) @@ -846,6 +851,7 @@ describe('background preflight failure (no orphaned child, by construction)', () id, ctx: scopeFiber.ctx, inject: () => {}, + options: {}, session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(parent) @@ -879,3 +885,107 @@ describe('background preflight failure (no orphaned child, by construction)', () expect(starts).toBe(0) }) }) + +describe('depth budget defaults and schema hiding', () => { + /** Mount the tool over a request-capturing provider with full capabilities. */ + async function captureSetup(config: Omit = {}) { + const requests: SubagentStartRequest[] = [] + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: async (request) => { + requests.push(request) + return { + id: SessionId(`capture-child-${requests.length}`), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture', ...config }) + return { ctx, requests } + } + + it('defaults maxDepth to 1 and forwards it in the start request', async () => { + const { ctx, requests } = await captureSetup() + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(requests[0]?.maxDepth).toBe(1) + }) + + it('denies its own toolName to a child at the depth cap', async () => { + // The child of a depth-0 parent under maxDepth 1 sits AT the cap: any + // delegation it attempted would be rejected, so the tool must not appear in + // its schema at all (prompt-face hiding; the service still rejects). + const { ctx, requests } = await captureSetup() + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(requests[0]?.toolFilter?.deny).toContain('subagent') + }) + + it('merges the cap denial into a configured tool filter', async () => { + const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] } }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent'])) + }) + + it('keeps the tool visible for a child below the cap', async () => { + const { ctx, requests } = await captureSetup({ maxDepth: 2 }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(requests[0]?.maxDepth).toBe(2) + expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent') + }) + + it('counts the parent by its persisted header depth when hiding', async () => { + // A resumed depth-1 parent under maxDepth 2: its child is AT the cap and + // must lose the tool even though the parent's runtime options carry no depth. + const { ctx, requests } = await captureSetup({ maxDepth: 2 }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: fakeAgent('resumed-parent', 1) }) + expect(requests[0]?.toolFilter?.deny).toContain('subagent') + }) + + it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'no-depth', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => { throw new Error('unreachable') }, + }) + await expect(ctx.plugin(tool, { provider: 'no-depth' })) + .rejects.toThrow(/provider-managed/) + }) + + it("'provider-managed' omits the cap so a capability-less provider mounts and starts", async () => { + const requests: SubagentStartRequest[] = [] + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'external', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async (request) => { + requests.push(request) + return { + id: SessionId('external-child'), + localAgent: undefined, + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'external', maxDepth: 'provider-managed' }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(requests[0]?.maxDepth).toBeUndefined() + expect(requests[0]?.toolFilter).toBeUndefined() + }) +}) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 938676e4b7..c5eaff1e0b 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -100,6 +100,18 @@ export interface Scenario { * {@link headerClass}. */ configPath?: string + /** + * Global tool names allowed to be ABSENT from a non-primary (child) session's + * request/header relative to the class pin — the delegation tool a child at + * its depth cap loses to tool-subagent's schema hiding. Each child header is + * compared against the pin minus exactly the declared names it actually + * omitted, so any other divergence (or an undeclared omission) still fails. + * A child that omitted a declared tool also skips the text-level initial + * system prompt pin: the prompt embeds the toolset (Code Mode SDK sections), + * so a reduced child cannot equal the full-composition golden — the + * structural header assertion remains its pin. Meaningless on the primary log. + */ + childToolOmissions?: string[] } /** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */ @@ -282,6 +294,36 @@ export function restorePinnedToolSchemas(header: unknown, schemas: readonly unkn return { ...header, tools: schemas } } +/** + * The pinned header with exactly the DECLARED omissions a child actually made + * removed from its tool list. A child at its depth cap legitimately lacks the + * delegation tool that spawned it (tool-subagent schema hiding); removing only + * declared-AND-actually-absent names keeps every other divergence — including + * an undeclared omission — a loud mismatch. + * @param pinned The class-pinned full header (tool schemas restored). + * @param actual The child session's normalized header under comparison. + * @param allowed The scenario's declared {@link Scenario.childToolOmissions}. + * @returns The expected header for this child log. + */ +export function applyChildToolOmissions(pinned: unknown, actual: unknown, allowed: readonly string[]): unknown { + if (pinned === null || typeof pinned !== 'object' || Array.isArray(pinned)) { + throw new Error('acp-snapshot: pinned request header must be an object') + } + const toolNames = (header: unknown): Set => { + const tools = (header as { tools?: unknown }).tools + return new Set(Array.isArray(tools) + ? tools.map(tool => (tool as { name?: unknown }).name).filter((name): name is string => typeof name === 'string') + : []) + } + const actualNames = toolNames(actual) + const pinnedTools = (pinned as { tools?: unknown[] }).tools ?? [] + const tools = pinnedTools.filter((tool) => { + const name = (tool as { name?: unknown }).name + return !(typeof name === 'string' && allowed.includes(name) && !actualNames.has(name)) + }) + return { ...pinned, tools } +} + /** * Render a normalized prompt as a repository-friendly Markdown snapshot. * Prompt text is unchanged except that a missing terminal newline is added so @@ -625,9 +667,20 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(headers.length) for (const [k, header] of headers.entries()) { const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0] + // A child (non-primary) log may omit declared delegation tools — + // schema hiding at the depth cap; see Scenario.childToolOmissions. + const childOmissions = logIndex === 0 ? [] : scenario.childToolOmissions ?? [] + const target = childOmissions.length === 0 + ? expected + : applyChildToolOmissions(expected, header, childOmissions) expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) - .toEqual(expected) - if (expectedChanges === 0) { + .toEqual(target) + // A child that omitted a declared tool cannot equal the text-level + // prompt pin (the prompt embeds the toolset); its header assertion + // above remains the structural pin. + const omittedDeclaredTool = target !== expected + && (target as { tools?: unknown[] }).tools?.length !== (expected as { tools?: unknown[] }).tools?.length + if (expectedChanges === 0 && !omittedDeclaredTool) { expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(initialPromptSnapshot) } diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 92e676b1d6..6d1210c46e 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -16,6 +16,7 @@ import { parseToolSchemasSnapshot, refreshFixtureReplacements, sessionFixtureNames, + applyChildToolOmissions, restorePinnedToolSchemas, stabilizeRefreshLog, unknownToolCallIds, @@ -366,6 +367,37 @@ describe('tool-schema snapshots', () => { }) }) +describe('applyChildToolOmissions', () => { + const pinned = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent' }, { name: 'subagent_fork' }] } + + it('removes exactly the declared tools the child actually omitted', () => { + const actual = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] } + expect(applyChildToolOmissions(pinned, actual, ['subagent', 'subagent_fork'])) + .toEqual({ system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] }) + }) + + it('keeps a declared tool the child still carries and an undeclared omission', () => { + // The child omitted `bash` (undeclared) — the expectation keeps it, so the + // equality assertion downstream still fails loudly on the real divergence. + const actual = { system: 's', tools: [{ name: 'subagent' }, { name: 'subagent_fork' }] } + expect(applyChildToolOmissions(pinned, actual, ['subagent'])) + .toEqual(pinned) + }) + + it('tolerates a headerless tool list and unnamed tool entries', () => { + expect(applyChildToolOmissions({ system: 's' }, { tools: 'not-an-array' }, ['subagent'])) + .toEqual({ system: 's', tools: [] }) + const unnamed = { system: 's', tools: [{ name: 42 }] } + expect(applyChildToolOmissions(unnamed, { tools: [] }, ['subagent'])).toEqual(unnamed) + }) + + it('rejects a non-object pinned header', () => { + expect(() => applyChildToolOmissions(null, {}, [])).toThrow(/must be an object/) + expect(() => applyChildToolOmissions([], {}, [])).toThrow(/must be an object/) + expect(() => applyChildToolOmissions('x', {}, [])).toThrow(/must be an object/) + }) +}) + describe('unknownToolCallIds', () => { it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => { const log = [ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8606d30895..60d3994623 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -39,11 +39,16 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -/** Register the descriptor needed to mount schema-producing consumers. */ +/** + * Register the descriptor needed to mount schema-producing consumers. Declares + * the full capability set of the shipped in-process providers so consumers + * mount under their shipped defaults (tool-subagent's default numeric maxDepth + * requires `depthLimit`). + */ function registerCatalogSubagentProvider(ctx: Context, name: string): void { const provider: SubagentProvider = { name, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), } From d231f3002434325f2b84d5cef57e41cd801b5e14 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 17:34:10 +0800 Subject: [PATCH 026/132] test(acp-snapshot): cover the child-omission branches synthetically The childToolOmissions comparison arms ran only through the examples snapshot suites, which the unit coverage gate does not count. Add an authored child-omission scenario to the synthetic replay suite: one scripted child omits the declared tool (header pin minus the omission, prompt pin skipped), one keeps the full set (pin and prompt compared verbatim), restoring 100% branch coverage on suite.ts. --- .../suite/child-omission/behavior.json | 116 ++++++++++++++++++ .../fixtures/suite/child-omission/input.json | 1 + .../suite/child-omission/session.1.jsonl | 2 + .../suite/child-omission/session.2.jsonl | 2 + .../suite/child-omission/session.jsonl | 3 + .../suite/child-omission/stdout.golden.jsonl | 5 + .../suite/child-omission/workspace/seed.txt | 1 + .../support/acp-snapshot/tests/suite.spec.ts | 4 + 8 files changed, 134 insertions(+) create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.golden.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json new file mode 100644 index 0000000000..b8624d4673 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json @@ -0,0 +1,116 @@ +{ + "prompt": "respond", + "echoWorkspace": true, + "logs": [ + { + "file": "b/parent.jsonl", + "lines": [ + { + "type": "session", + "id": "{{SID}}", + "createdAt": 200, + "cwd": "{{CWD}}" + }, + { + "type": "request/header", + "seq": 0, + "time": 5, + "data": { + "header": { + "config": { + "model": "fake" + }, + "system": "SYS PROMPT", + "tools": [ + { + "name": "t1", + "description": "D1", + "parameters": { + "type": "object" + } + } + ] + }, + "reason": "initial" + } + }, + { + "type": "assistant/chunk", + "seq": 1, + "time": 5, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "hi" + } + } + } + ] + }, + { + "file": "b/child1.jsonl", + "lines": [ + { + "type": "session", + "id": "eeeeeeee-1111-4222-8333-444444444444", + "createdAt": 300, + "cwd": "{{CWD}}", + "parentSession": "{{SID}}" + }, + { + "type": "request/header", + "seq": 0, + "time": 6, + "data": { + "header": { + "config": { + "model": "fake" + }, + "system": "SYS PROMPT", + "tools": [] + }, + "reason": "initial" + } + } + ] + }, + { + "file": "b/child2.jsonl", + "lines": [ + { + "type": "session", + "id": "ffffffff-2222-4333-8444-555555555555", + "createdAt": 400, + "cwd": "{{CWD}}", + "parentSession": "{{SID}}" + }, + { + "type": "request/header", + "seq": 0, + "time": 6, + "data": { + "header": { + "config": { + "model": "fake" + }, + "system": "SYS PROMPT", + "tools": [ + { + "name": "t1", + "description": "D1", + "parameters": { + "type": "object" + } + } + ] + }, + "reason": "initial" + } + } + ] + } + ] +} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json new file mode 100644 index 0000000000..60b9e363b5 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json @@ -0,0 +1 @@ +{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "plain" }] } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl new file mode 100644 index 0000000000..a844f891fc --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl @@ -0,0 +1,2 @@ +{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"} +{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl new file mode 100644 index 0000000000..c3bd629ad7 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl @@ -0,0 +1,2 @@ +{"type":"session","id":"ffffffff-2222-4333-8444-555555555555","createdAt":13,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"} +{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl new file mode 100644 index 0000000000..744998f959 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl @@ -0,0 +1,3 @@ +{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd"} +{"type":"request/header","seq":0,"time":11,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":1,"time":11,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"hi"}}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.golden.jsonl new file mode 100644 index 0000000000..d0242ae39f --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.golden.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt new file mode 100644 index 0000000000..c19e887d68 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt @@ -0,0 +1 @@ +seeded diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6d1210c46e..5bf90e0212 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -48,6 +48,10 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, + // Two scripted children under a declared omission: one omits t1 (header pin + // minus the declared tool, prompt pin skipped), one keeps the full set (pin + // and prompt compared verbatim) — the childToolOmissions branches. + { name: 'child-omission', hasModelTurn: true, recorded: false, headerClass: 'main', childToolOmissions: ['t1'] }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, From 6e790b95f25287ea984c8b44cd2d312b34db6459 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:11:48 +0800 Subject: [PATCH 027/132] fix(compact): complete master retarget integration Declare the tool-result pruning plugin in the examples workspace so the repl Cordis configuration resolves through plain Node and the Loader metadata gate. Express the validated single-node surface rewrite without a non-null assertion or an unreachable defensive branch, preserving both the runtime contract and per-file 100% coverage. --- examples/package.json | 1 + packages/core/session/src/surface.ts | 26 ++++++++++++-------------- pnpm-lock.yaml | 15 +++++++++------ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..a0206dd0f9 100644 --- a/examples/package.json +++ b/examples/package.json @@ -13,6 +13,7 @@ "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 67e28fd1ee..90a2181d53 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -198,20 +198,18 @@ function assertToolResultRewrite( if (shadowedSeqs.length !== 1) { throw new Error('tool/result surface replacement must rewrite exactly one current node') } - const originalSeq = shadowedSeqs[0] - if (originalSeq === undefined) { - throw new Error('tool/result surface replacement must rewrite exactly one current node') - } - const original = events[originalSeq] - if (original?.type !== 'tool/result') { - throw new Error('tool/result surface replacement must target a current tool/result') - } - const originalRest = { ...original.data } as Record - const replacementRest = { ...event.data } as Record - delete originalRest['content'] - delete replacementRest['content'] - if (!isDeepStrictEqual(originalRest, replacementRest)) { - throw new Error('tool/result surface replacement may change only content') + for (const originalSeq of shadowedSeqs) { + const original = events[originalSeq] + if (original?.type !== 'tool/result') { + throw new Error('tool/result surface replacement must target a current tool/result') + } + const originalRest = { ...original.data } as Record + const replacementRest = { ...event.data } as Record + delete originalRest['content'] + delete replacementRest['content'] + if (!isDeepStrictEqual(originalRest, replacementRest)) { + throw new Error('tool/result surface replacement may change only content') + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 456dfeca62..a1f6000b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:* + version: link:../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-fs-local': specifier: workspace:* version: link:../packages/fs/fs-local @@ -373,6 +376,9 @@ importers: '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../compact-tool-result-prune '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -385,9 +391,6 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../compact-tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2492,6 +2495,9 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -2609,9 +2615,6 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill From 1cb63c7a183249c89daccccfca763a03d81e9b5d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 18:27:44 +0800 Subject: [PATCH 028/132] fix(acp-agent): enable default filesystem suite --- ...mode-workspace-context.cordis.snapshot.yml | 12 +- .../code-mode-workspace-context.cordis.yml | 12 +- examples/acp-agent/composition.md | 9 ++ examples/acp-agent/cordis.yml | 13 ++ examples/acp-agent/fs.cordis.snapshot.yml | 12 +- examples/acp-agent/fs.cordis.yml | 12 +- .../system-prompt.golden.md | 33 ++++ .../tool-schemas.golden.json | 75 +++++++++ .../both-mode-turn/system-prompt.golden.md | 33 ++++ .../both-mode-turn/tool-schemas.golden.json | 75 +++++++++ .../code-mode-turn/system-prompt.golden.md | 33 ++++ .../model-switching/system-prompt.golden.md | 12 ++ .../model-switching/tool-schemas.golden.json | 150 ++++++++++++++++++ .../system-prompt.golden.md | 12 ++ .../tool-schemas.golden.json | 150 ++++++++++++++++++ .../skill-load/system-prompt.golden.md | 6 + .../skill-load/tool-schemas.golden.json | 75 +++++++++ .../text-turn/system-prompt.golden.md | 6 + .../text-turn/tool-schemas.golden.json | 75 +++++++++ .../workspace-context.cordis.snapshot.yml | 8 - .../acp-agent/workspace-context.cordis.yml | 9 -- packages/examples/acp-demo/README.md | 3 +- 22 files changed, 766 insertions(+), 59 deletions(-) diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml index a741091cce..8dad8832a0 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It -# enables the filesystem entries needed by this scenario and swaps in replay. +# Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It adds +# Code Mode to the default filesystem suite and swaps in replay. - id: base name: '@cordisjs/plugin-include' config: @@ -23,14 +23,6 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' - id: llm-replay diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index b932f06e64..71edf9750e 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,5 +1,5 @@ -# Code Mode workspace-context snapshot recording overlay. The scenario needs -# filesystem tools to trigger nested instruction discovery after a read. +# Code Mode workspace-context snapshot recording overlay. The default filesystem +# tools trigger nested instruction discovery after a read. - id: base name: '@cordisjs/plugin-include' config: @@ -20,13 +20,5 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 194ff3dee0..b3604caa84 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -18,6 +18,12 @@ flowchart LR cfg --> plugin_acp_approval plugin_acp_permission["permission
@deepseek-ai/dsh-permission"] cfg --> plugin_acp_permission + plugin_acp_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_acp_fs_local + plugin_acp_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_acp_fs_policy + plugin_acp_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_acp_tool_fs plugin_acp_acp_agent["acp-agent
@deepseek-ai/dsh-acp-demo"] cfg --> plugin_acp_acp_agent plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] @@ -58,6 +64,9 @@ flowchart LR | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index cb8890dae7..d4d7b90554 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -30,6 +30,19 @@ - id: permission name: '@deepseek-ai/dsh-permission' +# Workspace instructions and the model-facing read/write/edit tools share the +# local filesystem provider. fs-policy adds observed-version guards; fs-local's +# cwd is only a resolution default, not containment, so the permission preset +# above still confines bash only. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + # The ACP server app: the agent-spine-demo spine + JSONL persistence + the ACP bridge. # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it # (so it can harvest / isolate the log), else ./.sessions for the demo. diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index da0b2ca59b..a551251564 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless filesystem snapshots apply the filesystem and replay overlays directly -# because include patches cannot target entries behind a nested include. +# Keyless filesystem snapshots add low spill thresholds and the replay adapter +# directly because include patches cannot target entries behind a nested include. - id: base name: '@cordisjs/plugin-include' config: @@ -9,14 +9,6 @@ name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: spill-local name: '@deepseek-ai/dsh-spill-local' config: diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml index 52ca959a89..882384f319 100644 --- a/examples/acp-agent/fs.cordis.yml +++ b/examples/acp-agent/fs.cordis.yml @@ -1,20 +1,10 @@ -# Filesystem snapshots need the in-process local provider, policy gate, and -# model-facing tools. This explicit overlay is always full-access: the session -# permission preset controls bash only and cannot confine or unmount these plugins. +# Filesystem snapshots add low spill thresholds to the default filesystem suite. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: spill-local name: '@deepseek-ai/dsh-spill-local' config: diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index b8acef973c..257aa9a0ab 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. @@ -61,6 +67,26 @@ declare const tools: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -139,5 +165,12 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json index 978819fa1f..2e0f5efdf6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -102,6 +102,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", @@ -344,6 +398,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [] diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index f1a3b9ff92..cc8e2d1301 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. @@ -44,6 +50,26 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -122,5 +148,12 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json index edf1a7c001..068a1d80e0 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json @@ -45,6 +45,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", @@ -287,6 +341,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [] diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index f1a3b9ff92..cc8e2d1301 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. @@ -44,6 +50,26 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -122,5 +148,12 @@ declare const tools: { /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ args?: Record; }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; } ``` diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md index b9701e538c..e89336a2fe 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. @@ -23,6 +29,12 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json index 7c814257fb..7ccd09642b 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json @@ -45,6 +45,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -271,6 +325,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [ @@ -320,6 +395,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -546,6 +675,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ] ] diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md index 622bc4e23a..47a68e9a03 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. @@ -22,6 +28,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json index 7c814257fb..7ccd09642b 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json @@ -45,6 +45,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -271,6 +325,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [ @@ -320,6 +395,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -546,6 +675,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ] ] diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md index 43ecb9746f..ddf502a773 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json index e422a063da..4b08a1e365 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json @@ -45,6 +45,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -271,6 +325,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [] diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md index 43ecb9746f..ddf502a773 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md @@ -5,6 +5,12 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + Check the [exit code: N] marker on every bash result; investigate failures before moving on. Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json index e422a063da..4b08a1e365 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json @@ -45,6 +45,60 @@ ] } }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -271,6 +325,27 @@ "meta" ] } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + } + }, + "required": [ + "file_path", + "content" + ] + } } ], "changes": [] diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index e0b02cbb21..c5cccac519 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -25,13 +25,5 @@ Verify your work by running the code or tests. Keep answers brief and factual. - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 1b8c0279f0..9f422f65b9 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -21,12 +21,3 @@ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. - - insert: - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - - id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - - id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 1dbda9a08a..2fb444b10e 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -32,12 +32,13 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | | `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | +| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin | | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. +The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-fs-local`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one filesystem suite. ## The bin From 55a66024fa45161e20261659419c0388abea2386 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 18:38:35 +0800 Subject: [PATCH 029/132] fix: contain result observer rejections and correct bundle attribution --- packages/core/tools/src/index.ts | 8 ++++++-- packages/core/tools/tests/scoped.spec.ts | 9 +++++++-- packages/examples/agent-spine-demo/src/index.ts | 2 +- .../examples/agent-spine-demo/tests/agent-core.spec.ts | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5fc045ace2..223ae863d7 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -958,14 +958,18 @@ export class ToolRegistry extends Service { // Freeze the remaining mutable signal slot before observers receive the // shared WeakMap-keyable execution object. Object.freeze(exec) + const reportFailure = (error: unknown): void => { + this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`) + } const callbacks = this.ctx.events.dispatch('emit', [ scopeTarget(this, exec.agent), 'tools/result', exec, result, ]) for (const callback of callbacks) { try { - callback(exec, result) + const returned: unknown = callback(exec, result) + void Promise.resolve(returned).catch(reportFailure) } catch (error: unknown) { - this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`) + reportFailure(error) } } } diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 49ffc0bac9..843aeb3837 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -582,13 +582,18 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => { throw { toString: () => { throw new Error('coercion trap') } } }) + ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key }) + await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) expect(dispatchModes).toEqual(['emit']) - expect(warn).toHaveBeenCalledOnce() - expect(String(warn.mock.calls[0]?.[0])).toContain('') + expect(warn).toHaveBeenCalledTimes(2) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual(expect.arrayContaining([ + expect.stringContaining(''), + expect.stringContaining('async observer failure'), + ])) }) }) diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a4965ca457..dae21aae76 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -137,7 +137,7 @@ export function apply(ctx: Context, config: Config): void { const nestedDshHome = config.skills?.local?.dshHome if (config.dshHome !== undefined && nestedDshHome !== undefined && resolveDshHome(config.dshHome) !== resolveDshHome(nestedDshHome)) { - throw new Error('agent-core: dshHome and skills.local.dshHome must resolve to the same directory') + throw new Error('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory') } const dshHome = resolveDshHome(config.dshHome ?? nestedDshHome) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5d5b336ff9..2c2445bfb4 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -280,7 +280,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false, skills: { local: { dshHome: '/nested-dsh-home' } }, }) - }).toThrow(/must resolve to the same directory/) + }).toThrow('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory') }) it('places workspace instructions before the skill catalog in the session prefix', async () => { From a525776015c10c1a5c25c3cc625bb4a55f4f811a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:47:34 +0800 Subject: [PATCH 030/132] feat(goal): add persisted same-session goal domain --- docs/architecture.md | 10 +- docs/capability-seams.md | 4 + docs/config-catalog.md | 14 + docs/cordis-catalog/events.md | 23 + docs/cordis-catalog/services.md | 100 +++ docs/core-data-structures/core.md | 1 + docs/core-data-structures/goal.md | 139 ++++ docs/event-producer-consumer.md | 3 +- docs/glossary.md | 12 + docs/module-graph.md | 9 + docs/rfc/INDEX.md | 1 + ...rsisted-same-session-goal-domain.i18n.yaml | 6 + ...7-19-persisted-same-session-goal-domain.md | 62 ++ ...9-persisted-same-session-goal-domain.zh.md | 62 ++ .../tests/fixtures/goal/goal/cordis.yml | 24 + .../tests/fixtures/goal/goal/seed-goal.ts | 16 + examples/package.json | 1 + knip.json | 5 + packages/README.md | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 93 +++ packages/goal/README.md | 9 + packages/goal/goal/README.md | 45 ++ packages/goal/goal/package.json | 44 + packages/goal/goal/src/fold.ts | 379 +++++++++ packages/goal/goal/src/index.ts | 505 ++++++++++++ packages/goal/goal/src/render.ts | 21 + packages/goal/goal/src/runtime.ts | 27 + packages/goal/goal/src/types.ts | 168 ++++ packages/goal/goal/tests/goal.e2e.ts | 126 +++ packages/goal/goal/tests/goal.spec.ts | 749 ++++++++++++++++++ packages/goal/goal/tsconfig.json | 36 + packages/support/invariants/package.json | 1 + .../invariants/src/scoped-events.generated.ts | 2 + .../invariants/tests/invariants.spec.ts | 1 + packages/support/invariants/tsconfig.json | 3 + pnpm-lock.yaml | 34 + scripts/gen-cordis-catalog.ts | 6 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-module-graph.ts | 1 + scripts/type-equiv.manifest.json | 12 + tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/events.md | 28 +- website/zh-CN/api/harness/goals.md | 241 ++++++ 46 files changed, 3033 insertions(+), 6 deletions(-) create mode 100644 docs/core-data-structures/goal.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts create mode 100644 packages/goal/README.md create mode 100644 packages/goal/goal/README.md create mode 100644 packages/goal/goal/package.json create mode 100644 packages/goal/goal/src/fold.ts create mode 100644 packages/goal/goal/src/index.ts create mode 100644 packages/goal/goal/src/render.ts create mode 100644 packages/goal/goal/src/runtime.ts create mode 100644 packages/goal/goal/src/types.ts create mode 100644 packages/goal/goal/tests/goal.e2e.ts create mode 100644 packages/goal/goal/tests/goal.spec.ts create mode 100644 packages/goal/goal/tsconfig.json create mode 100644 website/zh-CN/api/harness/goals.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..2c6cd9c9ea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeepSeek Harness Architecture -The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel. +The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, including the shipped loop. ## Overview @@ -35,6 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | +| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus exact reads and relationship traces | @@ -54,9 +55,9 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. +The shipped loop drains prompts through checkpoints; every pause is exposed to plugins through services or events. -A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **turn** drains queued input in an append-only session until the model requests no more tools or plugin continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -108,7 +109,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. +Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through turn close and flush, and discards later steering while preserving queued prompts. `dsh-compact-basic` handles pressure and canonical overflow at these checkpoints; retry requires a balanced surface replacement ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). @@ -174,6 +175,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | +| Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 58641456b1..64ddcc40c5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -52,6 +52,8 @@ flowchart LR svc_agents["ctx.agents
Agent service"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] + pkg_goal["goal"] + svc_goals["ctx.goals
Same-session goal domain"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] pkg_bash_local["bash-local"] @@ -109,6 +111,7 @@ flowchart LR pkg_compact_basic --> svc_compact pkg_fs --> svc_fs pkg_fs_local --> svc_fs + pkg_goal --> svc_goals pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -217,6 +220,7 @@ flowchart LR | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | +| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225098e851..6473848ab3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -318,6 +318,20 @@ export interface Config { Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) +## `@deepseek-ai/dsh-goal` + +Requires: `agents` + +```ts config-catalog +/** Deployment defaults for goal creation. */ +export interface Config { + /** Total rounds used when a create request omits its own cap. */ + defaultMaxGoalRounds?: number +} +``` + +Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) + ## `@deepseek-ai/dsh-hooks-claude` Requires: `bash` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7d7472a33e..0f5a4650cd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -450,6 +450,29 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../c Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) +## `goal/*` + +### `goal/changed` — emit + +Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +```ts cordis-catalog +/** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +``` + +Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/goal/goal/src/types.ts:166`](../../packages/goal/goal/src/types.ts) + ## `llm/*` ### `llm/stream` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..934086dd97 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -479,6 +479,106 @@ Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](.. Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) +## `ctx.goals` — `GoalService` + +Goal service (`ctx.goals`) backed exclusively by the owning session log. + +```ts cordis-catalog +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec + +/** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ +get(agent: Agent): GoalView | undefined + +/** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ +create(agent: Agent, request: CreateGoalRequest): GoalView + +/** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ +edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView + +/** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ +pause(agent: Agent, ref: GoalRef): GoalView + +/** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ +resume(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ +complete(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView + +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): GoalView + +/** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ +clear(agent: Agent, ref: GoalRef): GoalRef +``` + +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) + +Source: [`packages/goal/goal/src/index.ts:97`](../../packages/goal/goal/src/index.ts) + ## `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1d45f3814a..25ded90954 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | +| [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md new file mode 100644 index 0000000000..76a1d0ed71 --- /dev/null +++ b/docs/core-data-structures/goal.md @@ -0,0 +1,139 @@ +# Same-session goals + +Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain RFC](../rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). + +## Identity and lifecycle + +`GoalId` is a [branded id](core.md#branded-ids). A caller mutates one exact revision through `GoalRef`; every accepted durable mutation increments the revision. + +```ts type-equiv +/** Compare-and-set identity for one exact goal revision. */ +interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} +``` + +The durable phase answers what happened to the objective. Process-local activation separately answers whether a continuation consumer may start another round. + +```ts type-equiv +/** Durable continuation phase. Activation is process-local and separate. */ +type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'usage-limited' + | 'budget-limited' + | 'complete' +``` + +```ts type-equiv +/** Full durable state written by every non-clear goal mutation. */ +interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Current goal projection, including values derived from the session log. */ +interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} +``` + +## Durable changes + +Every mutation is a `context/message` whose metadata is either a complete snapshot or a clear tombstone. The version, metadata, goal source, raw envelope, and rendered content form one replay invariant. + +```ts type-equiv +/** Full-snapshot goal mutation retained in a model-visible context event. */ +interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Tombstone retained when the current goal is cleared. */ +interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} +``` + +Goal state changes use round `0`. A continuation consumer attributes each admitted user-message turn with a positive, sequential round number and the current revision; replay rejects gaps, stale revisions, stopped phases, and cap overflow. + +```ts type-equiv +/** Message attribution for durable goal state and continuation rounds. */ +interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} +``` + +## Requests and notifications + +Creation separates caller omission from the resolved deployment choice. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. + +```ts type-equiv +/** Input whose omitted round cap is resolved by the service configuration. */ +interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Validated create input with every deployment default materialized. */ +interface CreateGoalSpec { + readonly objective: string + readonly maxGoalRounds: number +} +``` + +```ts type-equiv +/** Fields changed by an edit; at least one must be present. */ +interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} +``` + +```ts type-equiv +/** Live notification after one goal mutation has been accepted for logging. */ +interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} +``` + +## Service behavior + +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay, enforces exact-live-agent identity and compare-and-set mutations, overlays deferred injections, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..8f1fd333fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | @@ -27,6 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/glossary.md b/docs/glossary.md index 81b9b4f84a..74dc5e14d5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -15,3 +15,15 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. - **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent. - **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility.
+ +## goal + +- **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. +- **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. +- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. + +## loop hierarchy + +- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. +- **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0886877f0e..56934f8495 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -28,6 +28,9 @@ flowchart TD pkg_system_prompt["system-prompt"] pkg_tools["tools"] end + subgraph group_goal["packages/goal"] + pkg_goal["goal"] + end subgraph group_bash["packages/bash"] pkg_bash["bash"] pkg_bash_local["bash-local"] @@ -194,6 +197,11 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_goal --> pkg_agent + pkg_goal --> pkg_brand + pkg_goal --> pkg_llm + pkg_goal --> pkg_scope + pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent @@ -485,6 +493,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index bca5cf61f9..e6d4ef306b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml new file mode 100644 index 0000000000..771afcf5bf --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-persisted-same-session-goal-domain.md: ebf6168a4d8552d40e22db309f953f6f273511a9 +2026-07-19-persisted-same-session-goal-domain.zh.md: 5fd35ee8faffc1c53d257bee48f36d70838fdee1 diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md new file mode 100644 index 0000000000..ebf6168a4d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -0,0 +1,62 @@ +# RFC: Persisted same-session goal domain + +Status: implemented + +English | [中文](2026-07-19-persisted-same-session-goal-domain.zh.md) + +## Problem + +A long-running objective outlives one prompt, turn, or model request. Treating that objective as an in-memory loop variable loses it on process restart, while putting it only in UI state makes model behavior impossible to reconstruct. Treating every session turn as progress also charges unrelated human messages against an automatic-work budget. + +Durable lifecycle and permission to continue are different facts. A session may retain an active objective after restart or fork, but silently starting work when a user opens that session is surprising. The domain needs replayable state without persisted auto-execution authority, and it must remain a plugin on the public agent/session seams rather than a special case in the concrete loop. + +## Decision + +`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `resolveCreate()` materializes it before mutation. + +The durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, limit transitions, and clear disarm it. Edits preserve activation. Activation is never part of the persisted snapshot. + +### Durable record and replay + +Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. + +The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. + +When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; the durable log remains authoritative after restart. + +### Lifecycle and live activation + +At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. + +A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. + +### Service boundary + +The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`. + +## Testing + +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. + +## Alternatives considered + +- **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions. +- **Use hidden log-only events** — rejected because durable state that changes future model behavior must be model-visible and reconstructable under the repository's logging invariant. +- **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources. +- **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget. +- **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation. + +## Consequences + +- Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data. +- Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. +- Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them. +- Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. +- Round caps bound continuation count only; token, currency, time, and provider limits remain separate policy concerns. + +## Known limitations and deferred work + +- This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops. +- The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer. +- There is one current goal per session; parallel objective graphs and cross-session goal storage are absent. +- `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path. diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md new file mode 100644 index 0000000000..5fd35ee8fa --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -0,0 +1,62 @@ +# RFC: 持久的同会话目标领域 + +Status: implemented + +[English](2026-07-19-persisted-same-session-goal-domain.md) | 中文 + +## 问题 + +长时间运行的目标会跨越单个提示词、轮次或模型请求。若把该目标视为内存中的循环变量,进程重启时就会丢失;若只存放在 UI 状态中,又无法重建模型行为。若把会话中的每个轮次都视为目标进度,与自动工作无关的人类消息也会消耗预算。 + +持久生命周期与继续执行的权限是两个不同事实。会话在重启或 fork(派生)后可以保留活跃目标,但用户打开会话时静默启动工作并不符合直觉。该领域需要可回放的状态,却不能持久化自动执行权限;它还必须作为公共 agent(智能体)与会话接缝上的插件存在,而不是具体循环中的特例。 + +## 决策 + +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`resolveCreate()` 在变更前将其解析为完整值。 + +持久阶段包括 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 和 `complete`。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞、达到限制和清除都会解除激活。编辑保留激活态。持久快照绝不包含激活态。 + +### 持久记录与回放 + +每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 + +回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 + +当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重启后仍以持久日志为准。 + +### 生命周期与实时激活态 + +最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 + +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 + +### 服务边界 + +服务只接受在对应 id 下注册的同一个实时 `Agent` 对象。成功注入变更后,它会发出带作用域的 `goal/changed` 事件,并隔离监听器失败。策略消费者通过本服务、公共 `Agent` 接口和 `agent/*` 事件工作;目标领域既不导入也不修改 `dsh-agent-loop`。 + +## 测试 + +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 + +## 考虑过的替代方案 + +- **把目标存入独立数据库或会话头**——不予采纳,因为会话日志已经提供顺序、持久化、fork 前缀与可重建性;第二份存储会引入原子性和谱系问题。 +- **使用模型不可见的纯日志事件**——不予采纳,因为会改变后续模型行为的持久状态必须满足仓库日志不变量,保持模型可见且可重建。 +- **持久化激活态并自动重启**——不予采纳,因为打开或恢复会话时必须等待人类输入;持久阶段记录状态,而不是再次消耗资源的授权。 +- **把所有会话轮次都计为目标回合**——不予采纳,因为同一会话可以包含人类澄清、检查和无关工作;只有归属于目标的继续执行轮次才消耗该预算。 +- **向 `dsh-agent-loop` 添加目标状态或通用循环抽象**——不予采纳,因为状态与继续执行策略可以通过现有插件、`Agent` 动词和事件组合,而无需赋予默认循环实现特权。 + +## 后果 + +- 目标历史作为普通会话数据,在持久化、恢复、无关节点压缩和会话 fork 后继续保留。 +- 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 +- 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。 +- 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 +- 回合上限只约束继续执行次数;token、费用、时间和提供方限制仍属于独立策略。 + +## 已知限制与延期工作 + +- 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 +- 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 +- 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 +- `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。 diff --git a/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml new file mode 100644 index 0000000000..9d9e644cfa --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/cordis.yml @@ -0,0 +1,24 @@ +# Test-only composition: create one goal through a Loader-mounted lifecycle consumer. +- id: mock-llm + name: '../../../../src/mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 11 + +- id: seed-goal + name: './seed-goal.ts' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: mock + model: mock-echo + persona: 'Test the persisted goal domain.' + welcome: 'goal-domain e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts new file mode 100644 index 0000000000..ae3d0d231c --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal/seed-goal.ts @@ -0,0 +1,16 @@ +/** Test-only Loader plugin that creates a goal at the real session-start edge. */ + +import type { Context } from 'cordis' +import type {} from '@deepseek-ai/dsh-goal' + +export const name = 'seed-goal' +export const inject = ['goals'] + +export function apply(ctx: Context): void { + ctx.on('agent/session-start', (agent) => { + ctx.goals.create(agent, { + objective: 'Prove the composed goal survives in the session log', + maxGoalRounds: 7, + }) + }) +} diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..3a3815582a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/knip.json b/knip.json index 2c3465ab38..6f04363494 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -66,6 +67,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/goal": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 61a1e994c8..f2298a47fa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -9,6 +9,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 52f8477318..9262046683 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -250,6 +250,56 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'goals', + summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.', + methods: [ + { + signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec', + jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */', + }, + { + signature: 'get(agent: Agent): GoalView | undefined', + jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', + }, + { + signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView', + jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', + }, + { + signature: 'edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView', + jsDoc: '/**\n * Edit objective and/or round cap without changing phase.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param request - at least one replacement field.\n * @returns the edited view.\n */', + }, + { + signature: 'pause(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Pause an active goal and disarm automatic continuation.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the paused view.\n */', + }, + { + signature: 'resume(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Resume and arm a stopped goal, or rearm an active goal after a\n * session-start edge, while its round budget still has capacity.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the active view.\n */', + }, + { + signature: 'complete(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', + }, + { + signature: 'block(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */', + }, + { + signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */', + }, + { + signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView', + jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */', + }, + { + signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', + jsDoc: '/**\n * Clear the current goal while retaining a durable tombstone and history.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the tombstone ref whose revision is one past the cleared snapshot.\n */', + }, + ], + }, { key: 'llm', summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', @@ -746,6 +796,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */', summary: 'Single-slot decision for the next FileSystem.writeText.', }, + { + name: 'goal/changed', + mode: 'emit', + signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching context event is\n * already appended or queued in that agent\'s active tool-batch FIFO.\n * Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + summary: 'Goal mutation accepted by one live agent.', + }, { name: 'llm/stream', mode: 'waterfall', @@ -1076,6 +1133,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, + { + name: 'CreateGoalRequest', + declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', + }, + { + name: 'CreateGoalSpec', + declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}', + }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', @@ -1096,6 +1161,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DshEnvironmentKey', declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', }, + { + name: 'EditGoalRequest', + declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n}', @@ -1168,6 +1237,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GenericResultView', declaration: 'export interface GenericResultView {\n card: \'generic\';\n title?: string;\n content?: ContentBlock[];\n}', }, + { + name: 'GoalActivation', + declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';', + }, + { + name: 'GoalId', + declaration: 'export type GoalId = Branded<\'GoalId\'>;', + }, + { + name: 'GoalPhase', + declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';', + }, + { + name: 'GoalRef', + declaration: 'export interface GoalRef {\n readonly id: GoalId;\n readonly revision: number;\n}', + }, + { + name: 'GoalSnapshot', + declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}', + }, + { + name: 'GoalView', + declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', + }, { name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', diff --git a/packages/goal/README.md b/packages/goal/README.md new file mode 100644 index 0000000000..45f1b4f135 --- /dev/null +++ b/packages/goal/README.md @@ -0,0 +1,9 @@ +# goal/ — persisted same-session goals + +The goal family owns durable objective state independently of the model-facing tools and continuation policy that consume it. + +| Package | Role | ctx key | +|---|---|---| +| `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | + +Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md new file mode 100644 index 0000000000..57f366c844 --- /dev/null +++ b/packages/goal/goal/README.md @@ -0,0 +1,45 @@ +# @deepseek-ai/dsh-goal + +Event-sourced same-session goal state. The service retains one current completion objective in an agent's existing session while keeping permission to continue as process-local activation. The [goal-domain RFC](../../../docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the design rationale; the [goal type catalog](../../../docs/core-data-structures/goal.md) records the literal data shapes. + +## Config + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 256 +``` + +`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it. + +## Service contract + +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). + +At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. + +Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. + +Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. + +Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. + +## Extension points + +Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`. + +## Model Experience + +### Goal-state mutation + +**What the model sees**: Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. + +**Token effect**: Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. + +## Known Limitations and Deferred Work + +- **State, not scheduling** — this package does not decide when an armed goal continues, retry abnormal failures, or cancel an active turn; those policies belong to agent-seam consumers. +- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. +- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. +- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json new file mode 100644 index 0000000000..534c258c6b --- /dev/null +++ b/packages/goal/goal/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-goal", + "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.17.2" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts new file mode 100644 index 0000000000..d7256a38db --- /dev/null +++ b/packages/goal/goal/src/fold.ts @@ -0,0 +1,379 @@ +/** Pure replay fold and strict decoder for durable goal changes. */ + +import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { renderGoalChange } from './render.ts' +import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' +import type { + FoldedGoal, + GoalChangeMeta, + GoalClearChangeMeta, + GoalMessageSource, + GoalOperation, + GoalPhase, + GoalRef, + GoalSnapshot, + GoalSnapshotChangeMeta, +} from './types.ts' + +type ContextMessageEvent = Extract + +const SNAPSHOT_OPERATIONS: ReadonlySet> = new Set([ + 'create', + 'edit', + 'pause', + 'resume', + 'complete', + 'block', + 'mark-usage-limited', + 'mark-budget-limited', +]) +const PHASES: ReadonlySet = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + 'complete', +]) + +/** Mutable accumulator kept private to the pure fold. */ +export interface GoalFoldState { + goal: GoalSnapshot | undefined + roundsStarted: number + createdAt: number | undefined + updatedAt: number | undefined + lastRef: GoalRef | undefined + seenGoalIds: Set +} + +/** + * Build an empty replay accumulator. + * @returns mutable state with no current goal or prior ref. + */ +export function emptyGoalFoldState(): GoalFoldState { + return { + goal: undefined, + roundsStarted: 0, + createdAt: undefined, + updatedAt: undefined, + lastRef: undefined, + seenGoalIds: new Set(), + } +} + +/** Whether a value is a JSON record rather than an array. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Require one positive safe integer. */ +function positiveInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`goal change ${field} must be a positive safe integer`) + } + return value +} + +/** Require one non-negative safe integer. */ +function nonNegativeInteger(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`goal change ${field} must be a non-negative safe integer`) + } + return value +} + +/** Decode and validate one snapshot. */ +function decodeSnapshot(value: unknown): GoalSnapshot { + if (!isRecord(value)) throw new Error('goal change goal must be a record') + const keys = Object.keys(value).sort() + if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') { + throw new Error('goal change goal has an invalid shape') + } + if (typeof value['id'] !== 'string' || value['id'].length === 0) { + throw new Error('goal change goal.id must be a non-empty string') + } + if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0 + || value['objective'] !== value['objective'].trim()) { + throw new Error('goal change goal.objective must be non-empty and normalized') + } + if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) { + throw new Error('goal change goal.phase is invalid') + } + return { + id: GoalId(value['id']), + revision: positiveInteger(value['revision'], 'goal.revision'), + objective: value['objective'], + phase: value['phase'] as GoalPhase, + maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'), + } +} + +/** Decode and validate one ref. */ +function decodeRef(value: unknown): GoalRef { + if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') { + throw new Error('goal clear tombstone has an invalid shape') + } + if (typeof value['id'] !== 'string' || value['id'].length === 0) { + throw new Error('goal clear tombstone id must be a non-empty string') + } + return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') } +} + +/** + * Decode metadata that declares itself as a goal change. Unrelated metadata + * returns `undefined`; malformed goal metadata fails replay loudly. + * @param value - context-message metadata. + * @returns validated goal change or `undefined` for another metadata kind. + */ +export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { + if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined + if (value['version'] !== GOAL_CHANGE_VERSION) { + throw new Error(`unsupported goal change version ${String(value['version'])}`) + } + if (value['operation'] === 'clear') { + const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version'] + if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { + throw new Error('goal clear change has an invalid shape') + } + return { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'clear', + cleared: decodeRef(value['cleared']), + clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'), + } satisfies GoalClearChangeMeta + } + if (typeof value['operation'] !== 'string' + || !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude)) { + throw new Error('goal change operation is invalid') + } + const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version'] + if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { + throw new Error('goal snapshot change has an invalid shape') + } + const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt') + const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt') + if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt') + return { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: value['operation'] as Exclude, + goal: decodeSnapshot(value['goal']), + roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'), + createdAt, + updatedAt, + } satisfies GoalSnapshotChangeMeta +} + +/** Narrow model attribution to a valid goal source. */ +function goalSource(source: MessageSource): GoalMessageSource | undefined { + if (source.kind !== 'goal') return undefined + if (typeof source.goalId !== 'string' || source.goalId.length === 0 + || !Number.isSafeInteger(source.revision) || source.revision < 1 + || !Number.isSafeInteger(source.round) || source.round < 0) { + throw new Error('goal message source is invalid') + } + return source +} + +/** Require two snapshots to retain fields that only `edit` may replace. */ +function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void { + if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) { + throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`) + } +} + +/** Require one exact next revision of the current goal. */ +function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void { + if (next.id !== current.id || next.revision !== current.revision + 1) { + throw new Error(`goal ${operation} must advance the current goal by one revision`) + } +} + +/** Validate one non-create snapshot operation against the preceding projection. */ +function validateSnapshotTransition( + state: GoalFoldState, + change: GoalSnapshotChangeMeta, + current: GoalSnapshot, +): void { + const next = change.goal + requireNextRevision(current, next, change.operation) + /* v8 ignore next -- a current goal established by this fold always has an updatedAt */ + if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt') + if (change.createdAt !== state.createdAt + || change.updatedAt < state.updatedAt + || change.roundsStarted !== state.roundsStarted) { + throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`) + } + switch (change.operation) { + case 'edit': + if (next.phase !== current.phase) throw new Error('goal edit cannot change phase') + break + case 'pause': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition') + break + case 'resume': { + requireSameDefinition(current, next, change.operation) + const resumable: ReadonlySet = new Set([ + 'active', + 'paused', + 'blocked', + 'usage-limited', + 'budget-limited', + ]) + if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) { + throw new Error('goal resume has an invalid phase transition or exhausted round budget') + } + break + } + case 'complete': + requireSameDefinition(current, next, change.operation) + if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition') + break + case 'block': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition') + break + case 'mark-usage-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'usage-limited') { + throw new Error('goal mark-usage-limited has an invalid phase transition') + } + break + case 'mark-budget-limited': + requireSameDefinition(current, next, change.operation) + if (current.phase !== 'active' || next.phase !== 'budget-limited' + || state.roundsStarted < next.maxGoalRounds) { + throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget') + } + break + /* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */ + case 'create': + throw new Error('goal create cannot be validated as a current-goal transition') + default: + change.operation satisfies never + throw new Error('unknown goal snapshot operation') + /* v8 ignore stop */ + } +} + +/** + * Return the revision identity carried by a snapshot or tombstone. + * @param change - decoded goal mutation. + * @returns stable identity used to reconcile a deferred change with its log event. + */ +export function goalChangeRef(change: GoalChangeMeta): GoalRef { + return change.operation === 'clear' ? change.cleared : change.goal +} + +/** + * Validate and apply one decoded change to a mutable accumulator. + * @param state - preceding durable goal projection. + * @param change - decoded full snapshot or clear tombstone. + */ +export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void { + const ref = goalChangeRef(change) + if (change.operation === 'clear') { + const current = state.goal + if (current === undefined) throw new Error('goal clear requires a current goal') + requireNextRevision(current, change.cleared, change.operation) + /* v8 ignore next -- a current goal established by this fold always has an updatedAt */ + if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt') + if (change.clearedAt < state.updatedAt) { + throw new Error('goal clear timestamp cannot precede the current goal update') + } + state.goal = undefined + state.roundsStarted = 0 + state.createdAt = undefined + state.updatedAt = undefined + state.lastRef = ref + return + } + if (change.operation === 'create') { + if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0 + || (state.goal !== undefined && state.goal.phase !== 'complete') + || state.seenGoalIds.has(change.goal.id)) { + throw new Error('goal create requires a fresh active revision-one goal with zero rounds') + } + state.seenGoalIds.add(change.goal.id) + } else { + const current = state.goal + if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`) + validateSnapshotTransition(state, change, current) + } + state.goal = change.goal + state.roundsStarted = change.roundsStarted + state.createdAt = change.createdAt + state.updatedAt = change.updatedAt + state.lastRef = ref +} + +/** + * Decode and verify one model-visible goal context event without folding it. + * @param event - context event whose metadata and rendered content must agree. + * @returns validated change or `undefined` for an unrelated context event. + */ +export function decodeGoalEvent(event: ContextMessageEvent): GoalChangeMeta | undefined { + const change = decodeGoalChange(event.data.meta) + const source = goalSource(event.data.source) + if (change === undefined) { + if (source !== undefined) throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`) + return undefined + } + const ref = goalChangeRef(change) + if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) { + throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`) + } + if (event.data.envelope !== 'raw' + || JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) { + throw new Error(`goal change at session event ${event.seq} has mismatched model-visible content`) + } + return change +} + +/** + * Apply one session event and return its goal change, when present. + * @param state - mutable fold accumulator. + * @param event - next event in sequence order. + * @returns decoded change for pending-overlay reconciliation. + */ +export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined { + if (event.type === 'context/message') { + const change = decodeGoalEvent(event) + if (change === undefined) return undefined + applyGoalChange(state, change) + return change + } + if (event.type === 'user/message') { + const source = goalSource(event.data.source) + if (source !== undefined) { + const current = state.goal + if (current === undefined || current.phase !== 'active' || source.goalId !== current.id + || source.revision !== current.revision || source.round !== state.roundsStarted + 1 + || source.round > current.maxGoalRounds) { + throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`) + } + state.roundsStarted = source.round + } + } + return undefined +} + +/** + * Fold current goal state from a contiguous session event log. + * @param events - session events in sequence order. + * @returns a fresh durable projection; activation is deliberately absent. + */ +export function foldGoal(events: readonly SessionEvent[]): FoldedGoal { + const state = emptyGoalFoldState() + for (const event of events) applyGoalEvent(state, event) + return { + ...state.goal === undefined ? {} : { goal: { ...state.goal } }, + roundsStarted: state.roundsStarted, + ...state.createdAt === undefined ? {} : { createdAt: state.createdAt }, + ...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt }, + ...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } }, + } +} diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts new file mode 100644 index 0000000000..726ceb6c53 --- /dev/null +++ b/packages/goal/goal/src/index.ts @@ -0,0 +1,505 @@ +/** + * Same-session goal domain: event-sourced state, compare-and-set mutations, + * and process-local continuation activation. + * @module @deepseek-ai/dsh-goal + */ + +import { randomUUID } from 'node:crypto' +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session } from '@deepseek-ai/dsh-session' +import { + applyGoalChange, + applyGoalEvent, + decodeGoalEvent, + emptyGoalFoldState, + goalChangeRef, +} from './fold.ts' +import type { GoalFoldState } from './fold.ts' +import { renderGoalChange } from './render.ts' +import { + GOAL_CHANGE_VERSION, + GoalError, + GoalId, +} from './runtime.ts' +import type { + CreateGoalRequest, + CreateGoalSpec, + EditGoalRequest, + GoalActivation, + GoalChangeMeta, + GoalChanged, + GoalClearChangeMeta, + GoalOperation, + GoalPhase, + GoalRef, + GoalSnapshot, + GoalSnapshotChangeMeta, + GoalView, +} from './types.ts' + +export * from './types.ts' +export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' +export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' +export { renderGoalChange } from './render.ts' + +declare module 'cordis' { + interface Context { + goals: GoalService + } +} + +/** Deployment defaults for goal creation. */ +export interface Config { + /** Total rounds used when a create request omits its own cap. */ + defaultMaxGoalRounds?: number +} + +/** Resolved defaults. */ +export interface ResolvedConfig { + /** Validated positive safe-integer default round cap. */ + defaultMaxGoalRounds: number +} + +/** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ +interface GoalCache { + readonly state: GoalFoldState + activation: GoalActivation + observedSeq: number + readonly pending: GoalChangeMeta[] +} + +/** Validate a caller-visible positive safe-integer round cap. */ +function resolveMaxGoalRounds(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new GoalError('maxGoalRounds must be a positive safe integer', 'GOAL_INVALID_MAX_ROUNDS') + } + return value +} + +/** Validate and normalize an objective at the domain boundary. */ +function resolveObjective(value: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new GoalError('goal objective must be a non-empty string', 'GOAL_INVALID_OBJECTIVE') + } + return value.trim() +} + +/** Compare the complete canonical payloads used for deferred reconciliation. */ +function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ +export class GoalService extends Service { + static inject = ['agents'] + + static Config: z = z.object({ + defaultMaxGoalRounds: z.number().default(256), + }) + + private readonly resolved: ResolvedConfig + private readonly caches = new WeakMap() + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'goals') + this.resolved = { + defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), + } + ctx.on('agent/session-start', (agent) => { + this.cache(agent.session).activation = 'disarmed' + }) + } + + /** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ + resolveCreate(request: CreateGoalRequest): CreateGoalSpec { + return { + objective: resolveObjective(request.objective), + maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds), + } + } + + /** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ + get(agent: Agent): GoalView | undefined { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + return this.view(cache) + } + + /** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ + create(agent: Agent, request: CreateGoalRequest): GoalView { + const spec = this.resolveCreate(request) + const cache = this.prepareMutation(agent) + const current = cache.state.goal + if (current !== undefined && current.phase !== 'complete') { + throw new GoalError(`goal "${current.id}" already exists with phase "${current.phase}"`, 'GOAL_ALREADY_EXISTS') + } + const now = Date.now() + const goal: GoalSnapshot = { + id: GoalId(`goal-${randomUUID()}`), + revision: 1, + objective: spec.objective, + phase: 'active', + maxGoalRounds: spec.maxGoalRounds, + } + return this.commitSnapshot(agent, cache, 'create', goal, 0, now, now, 'armed') + } + + /** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ + edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (request.objective === undefined && request.maxGoalRounds === undefined) { + throw new GoalError('goal edit requires objective and/or maxGoalRounds', 'GOAL_INVALID_EDIT') + } + const goal: GoalSnapshot = { + ...current, + revision: current.revision + 1, + ...request.objective === undefined ? {} : { objective: resolveObjective(request.objective) }, + ...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds) }, + } + return this.commitCurrent(agent, cache, 'edit', goal, cache.activation) + } + + /** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ + pause(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'pause', ['active'], 'paused', 'disarmed') + } + + /** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ + resume(agent: Agent, ref: GoalRef): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'] + if (!resumable.includes(current.phase)) { + throw this.transitionError(current, 'resume', resumable) + } + if (current.phase === 'active' && cache.activation === 'armed') { + throw new GoalError(`goal "${current.id}" is already active and armed`, 'GOAL_INVALID_TRANSITION') + } + if (cache.state.roundsStarted >= current.maxGoalRounds) { + throw new GoalError( + `goal "${current.id}" exhausted ${current.maxGoalRounds} goal rounds; increase maxGoalRounds before resuming`, + 'GOAL_INVALID_TRANSITION', + ) + } + return this.commitCurrent(agent, cache, 'resume', this.withPhase(current, 'active'), 'armed') + } + + /** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ + complete(agent: Agent, ref: GoalRef): GoalView { + return this.transition( + agent, + ref, + 'complete', + ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'], + 'complete', + 'disarmed', + ) + } + + /** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ + block(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed') + } + + /** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ + markUsageLimited(agent: Agent, ref: GoalRef): GoalView { + return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed') + } + + /** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ + markBudgetLimited(agent: Agent, ref: GoalRef): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (current.phase !== 'active') { + throw this.transitionError(current, 'mark-budget-limited', ['active']) + } + if (cache.state.roundsStarted < current.maxGoalRounds) { + throw new GoalError( + `goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`, + 'GOAL_INVALID_TRANSITION', + ) + } + return this.commitCurrent( + agent, + cache, + 'mark-budget-limited', + this.withPhase(current, 'budget-limited'), + 'disarmed', + ) + } + + /** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ + clear(agent: Agent, ref: GoalRef): GoalRef { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + const tombstone: GoalRef = { id: current.id, revision: current.revision + 1 } + const change: GoalClearChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'clear', + cleared: tombstone, + clearedAt: this.nextMutationTime(cache), + } + this.commit(agent, cache, change, 'disarmed') + return { ...tombstone } + } + + /** Resolve and validate the cache used by a mutation. */ + private prepareMutation(agent: Agent): GoalCache { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + return cache + } + + /** Reject stale or missing current-state refs. */ + private expectCurrent(cache: GoalCache, ref: GoalRef): GoalSnapshot { + const current = cache.state.goal + if (current === undefined) throw new GoalError('no current goal', 'GOAL_NOT_FOUND') + if (ref.id !== current.id || ref.revision !== current.revision) { + throw new GoalError( + `stale goal ref "${ref.id}" revision ${ref.revision}; current is "${current.id}" revision ${current.revision}`, + 'GOAL_STALE_REVISION', + ) + } + return current + } + + /** Enforce exact live-agent identity rather than trusting a matching id. */ + private assertLive(agent: Agent): void { + if (this.ctx.agents.get(agent.id) !== agent || agent.status === 'disposed') { + throw new GoalError(`agent "${agent.id}" is not live in this registry`, 'GOAL_AGENT_NOT_LIVE') + } + } + + /** Return the per-session cache, folding a seed once with activation disarmed. */ + private cache(session: Session): GoalCache { + let cache = this.caches.get(session) + if (cache !== undefined) return cache + const state = emptyGoalFoldState() + for (const event of session.events) applyGoalEvent(state, event) + cache = { + state, + activation: 'disarmed', + observedSeq: session.seq, + pending: [], + } + this.caches.set(session, cache) + return cache + } + + /** Incrementally observe durable events without losing deferred mutations. */ + private sync(session: Session, cache: GoalCache): void { + for (const event of session.events.slice(cache.observedSeq)) { + if (event.type === 'context/message') { + const change = decodeGoalEvent(event) + if (change !== undefined) { + const pending = cache.pending[0] + if (pending !== undefined && sameChange(pending, change)) { + cache.pending.shift() + continue + } + } + } + applyGoalEvent(cache.state, event) + } + cache.observedSeq = session.seq + } + + /** Build a new revision with one replacement phase. */ + private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot { + return { ...current, revision: current.revision + 1, phase } + } + + /** Shared validated phase transition. */ + private transition( + agent: Agent, + ref: GoalRef, + operation: Exclude, + allowed: readonly GoalPhase[], + phase: GoalPhase, + activation: GoalActivation, + ): GoalView { + const cache = this.prepareMutation(agent) + const current = this.expectCurrent(cache, ref) + if (!allowed.includes(current.phase)) throw this.transitionError(current, operation, allowed) + return this.commitCurrent(agent, cache, operation, this.withPhase(current, phase), activation) + } + + /** Render a stable invalid-transition error. */ + private transitionError(current: GoalSnapshot, operation: GoalOperation, allowed: readonly GoalPhase[]): GoalError { + return new GoalError( + `cannot ${operation} goal "${current.id}" from phase "${current.phase}"; expected ${allowed.join(' or ')}`, + 'GOAL_INVALID_TRANSITION', + ) + } + + /** Commit a mutation that retains the current goal's derived counters/times. */ + private commitCurrent( + agent: Agent, + cache: GoalCache, + operation: Exclude, + goal: GoalSnapshot, + activation: GoalActivation, + ): GoalView { + const createdAt = cache.state.createdAt + /* v8 ignore next -- strict replay and every snapshot commit set createdAt whenever a current goal exists */ + if (createdAt === undefined) throw new Error('current goal cache lacks createdAt') + return this.commitSnapshot( + agent, + cache, + operation, + goal, + cache.state.roundsStarted, + createdAt, + this.nextMutationTime(cache), + activation, + ) + } + + /** Clamp a current goal's next timestamp across backward wall-clock movement. */ + private nextMutationTime(cache: GoalCache): number { + const updatedAt = cache.state.updatedAt + /* v8 ignore next -- strict replay and every snapshot commit set updatedAt whenever a current goal exists */ + if (updatedAt === undefined) throw new Error('current goal cache lacks updatedAt') + return Math.max(Date.now(), updatedAt) + } + + /** Build and commit one full-snapshot mutation. */ + private commitSnapshot( + agent: Agent, + cache: GoalCache, + operation: Exclude, + goal: GoalSnapshot, + roundsStarted: number, + createdAt: number, + updatedAt: number, + activation: GoalActivation, + ): GoalView { + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation, + goal, + roundsStarted, + createdAt, + updatedAt, + } + this.commit(agent, cache, change, activation) + const view = this.view(cache) + /* v8 ignore next -- applyGoalChange installs the snapshot immediately before this read */ + if (view === undefined) throw new Error('snapshot commit cleared the goal unexpectedly') + return view + } + + /** Accept one mutation into the agent log/FIFO, cache, and live event stream. */ + private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void { + const ref = goalChangeRef(change) + // snapshotJsonValue preserves its input type for callers that already have + // a JsonValue; this interface is structurally JSON but intentionally has no + // index signature, so narrow the validated output at this boundary. + const meta = snapshotJsonValue(change) as JsonValue | undefined + /* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */ + if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable') + agent.inject(renderGoalChange(change), { + source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, + envelope: 'raw', + meta, + }) + cache.pending.push(change) + applyGoalChange(cache.state, change) + cache.activation = activation + this.sync(agent.session, cache) + const goal = this.view(cache) + const notification: GoalChanged = { + operation: change.operation, + ref: { ...ref }, + ...goal === undefined ? {} : { goal }, + } + agentEvents(this.ctx, agent).emit('goal/changed', notification) + } + + /** Build a detached current view. */ + private view(cache: GoalCache): GoalView | undefined { + const goal = cache.state.goal + const createdAt = cache.state.createdAt + const updatedAt = cache.state.updatedAt + if (goal === undefined) return undefined + /* v8 ignore next 3 -- strict replay and snapshot commits establish both timestamps with every current goal */ + if (createdAt === undefined || updatedAt === undefined) { + throw new Error(`goal "${goal.id}" cache lacks timestamps`) + } + return { + ...goal, + roundsStarted: cache.state.roundsStarted, + createdAt, + updatedAt, + activation: cache.activation, + } + } +} + +export default GoalService diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts new file mode 100644 index 0000000000..f84a1b51ea --- /dev/null +++ b/packages/goal/goal/src/render.ts @@ -0,0 +1,21 @@ +/** Model-visible rendering for durable goal mutations. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GoalChangeMeta } from './types.ts' + +/** + * Render a complete goal snapshot or clear tombstone without hidden prose. + * @param change - durable goal change metadata. + * @returns the single raw context block logged for model reconstruction. + */ +export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] { + const payload = change.operation === 'clear' + ? { cleared: change.cleared, clearedAt: change.clearedAt } + : { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } + return [{ type: 'text', text: `${JSON.stringify(payload)}` }] +} diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts new file mode 100644 index 0000000000..9cd656ccae --- /dev/null +++ b/packages/goal/goal/src/runtime.ts @@ -0,0 +1,27 @@ +/** Runtime constructors and protocol constants for the goal domain. */ + +import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' + +/** Version of the goal change metadata embedded in `context/message`. */ +export const GOAL_CHANGE_VERSION = 1 + +/** + * Brand a string as a goal id. + * @param id - raw goal identifier. + * @returns the same string with the compile-time brand. + */ +export function GoalId(id: string): GoalIdType { + return id as GoalIdType +} + +/** Error returned by the goal domain boundary. */ +export class GoalError extends Error { + /** + * @param message - human-readable rejection reason. + * @param code - stable machine-routable classification. + */ + constructor(message: string, public readonly code: GoalErrorCode) { + super(message) + this.name = 'GoalError' + } +} diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts new file mode 100644 index 0000000000..ef40e997ef --- /dev/null +++ b/packages/goal/goal/src/types.ts @@ -0,0 +1,168 @@ +/** + * Durable and live vocabulary for one same-session goal. + * @module @deepseek-ai/dsh-goal/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** Identifies one goal across its durable revisions. */ +export type GoalId = Branded<'GoalId'> + +/** Compare-and-set identity for one exact goal revision. */ +export interface GoalRef { + /** Stable goal identity. */ + readonly id: GoalId + /** Positive revision; every durable mutation increments it. */ + readonly revision: number +} + +/** Durable continuation phase. Activation is process-local and separate. */ +export type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'usage-limited' + | 'budget-limited' + | 'complete' + +/** Full durable state written by every non-clear goal mutation. */ +export interface GoalSnapshot extends GoalRef { + /** Human-requested completion objective. */ + readonly objective: string + /** Durable lifecycle phase. */ + readonly phase: GoalPhase + /** Total admitted goal-round cap. */ + readonly maxGoalRounds: number +} + +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + +/** Goal state-changing verbs recorded in the durable change metadata. */ +export type GoalOperation = + | 'create' + | 'edit' + | 'pause' + | 'resume' + | 'complete' + | 'block' + | 'mark-usage-limited' + | 'mark-budget-limited' + | 'clear' + +/** Full-snapshot goal mutation retained in a model-visible context event. */ +export interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} + +/** Tombstone retained when the current goal is cleared. */ +export interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} + +/** Durable metadata union carried by a goal-owned `context/message`. */ +export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta + +/** Message attribution for durable goal state and continuation rounds. */ +export interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + goal: GoalMessageSource + } +} + +/** Pure replay fold of durable goal facts. */ +export interface FoldedGoal { + /** Current goal, absent after a clear or before the first create. */ + readonly goal?: GoalSnapshot + /** Highest admitted round for the current goal. */ + readonly roundsStarted: number + /** Current goal creation time, absent without a current goal. */ + readonly createdAt?: number + /** Current goal mutation time, absent without a current goal. */ + readonly updatedAt?: number + /** Latest mutation ref, including a clear tombstone. */ + readonly lastRef?: GoalRef +} + +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Validated create input with every deployment default materialized. */ +export interface CreateGoalSpec { + readonly objective: string + readonly maxGoalRounds: number +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + +/** Live notification after one goal mutation has been accepted for logging. */ +export interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} + +/** Stable error codes for rejected goal reads and mutations. */ +export type GoalErrorCode = + | 'GOAL_AGENT_NOT_LIVE' + | 'GOAL_NOT_FOUND' + | 'GOAL_ALREADY_EXISTS' + | 'GOAL_STALE_REVISION' + | 'GOAL_INVALID_OBJECTIVE' + | 'GOAL_INVALID_MAX_ROUNDS' + | 'GOAL_INVALID_EDIT' + | 'GOAL_INVALID_TRANSITION' + +declare module 'cordis' { + interface Events { + /** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + } +} diff --git a/packages/goal/goal/tests/goal.e2e.ts b/packages/goal/goal/tests/goal.e2e.ts new file mode 100644 index 0000000000..0942248b78 --- /dev/null +++ b/packages/goal/goal/tests/goal.e2e.ts @@ -0,0 +1,126 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const REPLY = 'You said: "hello". Try "echo " to see a tool call.' + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runOneTurn(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-domain-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!inputClosed && stdout.includes(REPLY)) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`goal-domain e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-domain e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('hello\n') + }) +} + +describe('goal domain through a real cordis.yml and stdio process', () => { + it('persists the Loader-created snapshot without starting a goal round', async () => { + const { stdout, stderr } = await runOneTurn() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-domain e2e ready.') + expect(stdout).toContain(REPLY) + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const contexts = events.filter(event => event.type === 'context/message' + && event.data.source.kind === 'goal') + expect(contexts).toHaveLength(1) + const context = contexts[0] + if (context?.type !== 'context/message') throw new Error('expected goal context event') + const change = decodeGoalChange(context.data.meta) + if (change === undefined) throw new Error('expected durable goal change') + expect(change).toMatchObject({ + operation: 'create', + roundsStarted: 0, + goal: { + revision: 1, + objective: 'Prove the composed goal survives in the session log', + phase: 'active', + maxGoalRounds: 7, + }, + }) + expect(context.data.envelope).toBe('raw') + expect(context.data.content).toEqual(renderGoalChange(change)) + expect(JSON.stringify(context)).not.toContain('activation') + expect(events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'goal')).toHaveLength(0) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts new file mode 100644 index 0000000000..03b00dd699 --- /dev/null +++ b/packages/goal/goal/tests/goal.spec.ts @@ -0,0 +1,749 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import GoalService, { + GoalError, + GoalId, + decodeGoalChange, + foldGoal, + renderGoalChange, +} from '@deepseek-ai/dsh-goal' +import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' + +interface DeferredInjection { + content: ContentBlock[] + options: InjectOptions | undefined +} + +interface StubAgent { + agent: Agent + session: Session + deferred: DeferredInjection[] + setDeferred(value: boolean): void + setStatus(value: AgentStatus): void + drain(): void +} + +/** Number the next balanced one-shot injection turn. */ +function nextTurn(session: Session): number { + return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 +} + +/** Mirror the public Agent.inject idle/open-turn contract for domain tests. */ +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { + const source: MessageSource = options?.source ?? { kind: 'user' } + const context = { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + } + const last = session.events.at(-1) + const open = last !== undefined && last.type !== 'turn/end' + if (open) { + session.append('context/message', context, { surfaceOp: 'append' }) + return + } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', context, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +/** Build a registry-compatible agent around one concrete session. */ +function stubAgentForSession(session: Session): StubAgent { + const id = session.id + const deferred: DeferredInjection[] = [] + let shouldDefer = false + let status: AgentStatus = 'idle' + const agent: Agent = { + id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + send() {}, + steer() {}, + inject(content, options) { + if (shouldDefer) deferred.push({ content, options }) + else appendInjection(session, content, options) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + return { + agent, + session, + deferred, + setDeferred(value) { shouldDefer = value }, + setStatus(value) { status = value }, + drain() { + shouldDefer = false + for (const injection of deferred.splice(0)) appendInjection(session, injection.content, injection.options) + }, + } +} + +/** Build a registry-compatible agent with controllable context deferral. */ +function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent { + return stubAgentForSession(new Session(SessionId(rawId), seed)) +} + +async function harness(config: { defaultMaxGoalRounds?: number } = {}) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService, config) + const stub = stubAgent(`goal-test-${Math.random()}`) + ctx.agents.register(stub.agent) + return { ctx, ...stub } +} + +/** Append one admitted goal round as a balanced user-message turn. */ +function appendRound(session: Session, ref: GoalRef, round: number): void { + const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content: [{ type: 'text', text: `round ${round}` }], source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +describe('GoalService creation and replay', () => { + it('resolves the configured default and writes one balanced raw context snapshot', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) + const seen: string[] = [] + ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + + expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({ + objective: 'finish the feature', + maxGoalRounds: 17, + }) + const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) + + expect(goal).toMatchObject({ + objective: 'finish the feature', + phase: 'active', + revision: 1, + maxGoalRounds: 17, + roundsStarted: 0, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + activation: 'armed', + }) + expect(goal.id).toMatch(/^goal-/) + expect(seen).toEqual(['create']) + expect(session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + const context = session.events[1] + expect(context?.type).toBe('context/message') + if (context?.type !== 'context/message') throw new Error('expected goal context') + expect(context.data.envelope).toBe('raw') + expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 }) + const change = decodeGoalChange(context.data.meta) + if (change === undefined) throw new Error('expected decoded goal change') + expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) + expect(context.data.content).toEqual(renderGoalChange(change)) + expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }]) + expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) + vi.useRealTimers() + }) + + it('uses 256 rounds by default and validates create input at the owning resolver', async () => { + const { ctx, agent } = await harness() + expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 }) + expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_OBJECTIVE', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_MAX_ROUNDS', + })) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) + expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) + }) + + it('also resolves the default when constructed directly without Cordis config normalization', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const goals = new GoalService(ctx) + expect(goals.resolveCreate({ objective: 'direct' })).toEqual({ + objective: 'direct', + maxGoalRounds: 256, + }) + }) + + it('rejects invalid direct configuration', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_MAX_ROUNDS', + })) + }) + + it('restores a seeded goal and rounds with activation disarmed', async () => { + const first = await harness() + const created = first.ctx.goals.create(first.agent, { objective: 'seed me', maxGoalRounds: 9 }) + appendRound(first.session, created, 1) + appendRound(first.session, created, 2) + + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const resumed = stubAgent('seeded-goal', first.session.events) + ctx.agents.register(resumed.agent) + expect(ctx.goals.get(resumed.agent)).toMatchObject({ + id: created.id, + roundsStarted: 2, + activation: 'disarmed', + }) + }) + + it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) + ctx.agents.register(parent.agent) + const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 }) + appendRound(parent.session, goal, 1) + + const child = stubAgentForSession(ctx.sessions.fork(parent.session)) + ctx.agents.register(child.agent) + expect(ctx.goals.get(child.agent)).toMatchObject({ + id: goal.id, + objective: goal.objective, + roundsStarted: 1, + activation: 'disarmed', + }) + expect(child.session.header.parentSession).toBe(parent.session.id) + expect(child.session.header.seedLength).toBe(parent.session.seq) + }) + + it('disarms live activation on every session-start edge', async () => { + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) + expect(goal.activation).toBe('armed') + agentEvents(ctx, agent).emit('agent/session-start', 'resume') + expect(ctx.goals.get(agent)?.activation).toBe('disarmed') + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) + expect(() => foldGoal(session.events)).not.toThrow() + }) + + it('requires the exact live registry instance for reads and mutations', async () => { + const { ctx, agent } = await harness() + const impostor = { ...agent, session: new Session(agent.id) } + expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) + expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ + code: 'GOAL_AGENT_NOT_LIVE', + })) + }) + + it('rejects a disposed live object even before registry teardown', async () => { + const test = await harness() + test.setStatus('disposed') + expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) + }) +}) + +describe('GoalService mutations', () => { + it('edits with compare-and-set revisions and rejects empty edits', async () => { + const { ctx, agent } = await harness() + const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 }) + expect(() => ctx.goals.edit(agent, created, {})).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_EDIT' })) + const objective = ctx.goals.edit(agent, created, { objective: ' new ' }) + expect(objective).toMatchObject({ objective: 'new', maxGoalRounds: 4, revision: 2, activation: 'armed' }) + expect(() => ctx.goals.edit(agent, created, { maxGoalRounds: 8 })).toThrow(expect.objectContaining({ + code: 'GOAL_STALE_REVISION', + })) + const cap = ctx.goals.edit(agent, objective, { maxGoalRounds: 8 }) + expect(cap).toMatchObject({ objective: 'new', maxGoalRounds: 8, revision: 3 }) + expect(() => ctx.goals.edit(agent, cap, { objective: ' ' })).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_OBJECTIVE', + })) + }) + + it('supports pause, resume, block, usage-limit, and completion transitions', async () => { + const { ctx, agent } = await harness() + let goal = ctx.goals.create(agent, { objective: 'lifecycle' }) + goal = ctx.goals.pause(agent, goal) + expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 }) + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 }) + goal = ctx.goals.block(agent, goal) + expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' }) + goal = ctx.goals.resume(agent, goal) + goal = ctx.goals.markUsageLimited(agent, goal) + expect(goal.phase).toBe('usage-limited') + goal = ctx.goals.resume(agent, goal) + goal = ctx.goals.pause(agent, goal) + goal = ctx.goals.complete(agent, goal) + expect(goal).toMatchObject({ phase: 'complete', activation: 'disarmed' }) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + }) + + it('allows completion from every stopped phase and replacement only after completion', async () => { + const phases = ['paused', 'blocked', 'usage-limited'] as const + for (const phase of phases) { + const { ctx, agent } = await harness() + let goal = ctx.goals.create(agent, { objective: phase }) + goal = phase === 'paused' + ? ctx.goals.pause(agent, goal) + : phase === 'blocked' + ? ctx.goals.block(agent, goal) + : ctx.goals.markUsageLimited(agent, goal) + const complete = ctx.goals.complete(agent, goal) + const replacement = ctx.goals.create(agent, { objective: `after ${phase}` }) + expect(complete.phase).toBe('complete') + expect(replacement.id).not.toBe(complete.id) + expect(replacement.revision).toBe(1) + } + }) + + it('rejects replacement and invalid phase transitions while a resumable goal exists', async () => { + const { ctx, agent } = await harness() + const goal = ctx.goals.create(agent, { objective: 'still active' }) + expect(() => ctx.goals.create(agent, { objective: 'replacement' })).toThrow(expect.objectContaining({ + code: 'GOAL_ALREADY_EXISTS', + })) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + const paused = ctx.goals.pause(agent, goal) + expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + }) + + it('enforces the goal-round cap before budget limiting and resuming', async () => { + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 }) + appendRound(session, goal, 1) + expect(ctx.goals.get(agent)?.roundsStarted).toBe(1) + expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_TRANSITION', + })) + appendRound(session, goal, 2) + goal = ctx.goals.markBudgetLimited(agent, goal) + expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' }) + expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) + goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 }) + goal = ctx.goals.resume(agent, goal) + expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' }) + appendRound(session, goal, 3) + goal = ctx.goals.markBudgetLimited(agent, goal) + expect(ctx.goals.complete(agent, goal).phase).toBe('complete') + }) + + it('clears through a revisioned tombstone and permits a fresh goal', async () => { + const { ctx, agent, session } = await harness() + const goal = ctx.goals.create(agent, { objective: 'temporary' }) + const tombstone = ctx.goals.clear(agent, goal) + expect(tombstone).toEqual({ id: goal.id, revision: 2 }) + expect(ctx.goals.get(agent)).toBeUndefined() + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone }) + expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' })) + const next = ctx.goals.create(agent, { objective: 'fresh' }) + expect(next.id).not.toBe(goal.id) + }) + + it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => { + vi.useFakeTimers() + vi.setSystemTime(100) + const { ctx, agent, session } = await harness() + let goal = ctx.goals.create(agent, { objective: 'monotonic time' }) + vi.setSystemTime(90) + goal = ctx.goals.pause(agent, goal) + expect(goal.updatedAt).toBe(100) + vi.setSystemTime(80) + ctx.goals.clear(agent, goal) + const clear = session.events + .filter(event => event.type === 'context/message') + .map(event => decodeGoalChange(event.data.meta)) + .at(-1) + expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) + expect(() => foldGoal(session.events)).not.toThrow() + vi.useRealTimers() + }) + + it('contains goal notification failures and preserves later listeners', async () => { + const { ctx, agent } = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: string[] = [] + ctx.on('goal/changed', () => { throw new Error('broken observer') }) + ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') + expect(seen).toEqual(['create']) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) + }) + + it('preserves multiple pending revisions until deferred injections enter the log', async () => { + const test = await harness() + const { ctx, agent, session, deferred } = test + test.setDeferred(true) + let goal = ctx.goals.create(agent, { objective: 'deferred', maxGoalRounds: 5 }) + goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) + goal = ctx.goals.pause(agent, goal) + expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) + expect(deferred).toHaveLength(3) + expect(session.events).toHaveLength(0) + + appendInjection(session, [{ type: 'text', text: 'unrelated' }], { source: { kind: 'plugin', plugin: 'test' } }) + expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) + test.drain() + expect(deferred).toHaveLength(0) + expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) + expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) + }) + + it('rejects deferred goal mutations that enter the log out of FIFO order', async () => { + const test = await harness() + test.setDeferred(true) + const created = test.ctx.goals.create(test.agent, { objective: 'ordered' }) + test.ctx.goals.edit(test.agent, created, { objective: 'ordered edit' }) + const second = test.deferred[1] + if (second === undefined) throw new Error('expected a second deferred goal mutation') + appendInjection(test.session, second.content, second.options) + expect(() => test.ctx.goals.get(test.agent)).toThrow('advance the current goal') + }) + + it('observes a valid goal snapshot appended after an empty cache was established', async () => { + const { ctx, agent, session } = await harness() + expect(ctx.goals.get(agent)).toBeUndefined() + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-external'), + revision: 1, + objective: 'observe external append', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 12, + updatedAt: 12, + } + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: renderGoalChange(change), source, envelope: 'raw', meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + + expect(ctx.goals.get(agent)).toMatchObject({ + id: change.goal.id, + objective: change.goal.objective, + activation: 'disarmed', + }) + }) +}) + +describe('goal replay validation', () => { + function snapshotChange(overrides: Partial = {}): GoalSnapshotChangeMeta { + return { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-validation'), + revision: 1, + objective: 'validate', + phase: 'active', + maxGoalRounds: 2, + }, + roundsStarted: 0, + createdAt: 10, + updatedAt: 10, + ...overrides, + } + } + + function appendChange( + session: Session, + change: GoalChangeMeta, + overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}, + ): void { + const source = overrides.source ?? { + kind: 'goal', + goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id, + revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision, + round: 0, + } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: overrides.content ?? renderGoalChange(change), + source, + envelope: overrides.envelope ?? 'raw', + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + function oneChange(change: GoalChangeMeta, overrides: { content?: ContentBlock[]; source?: MessageSource; envelope?: 'raw' } = {}) { + const session = new Session(SessionId(`validation-${Math.random()}`)) + appendChange(session, change, overrides) + return session.events + } + + function mutation( + current: GoalSnapshotChangeMeta, + operation: Exclude, + phase: GoalSnapshotChangeMeta['goal']['phase'], + overrides: Partial = {}, + ): GoalSnapshotChangeMeta { + return { + ...current, + operation, + goal: { ...current.goal, revision: current.goal.revision + 1, phase }, + updatedAt: current.updatedAt + 1, + ...overrides, + } + } + + function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { + const session = new Session(SessionId(`validation-pair-${Math.random()}`)) + appendChange(session, first) + appendChange(session, second) + return foldGoal(session.events) + } + + it('ignores unrelated metadata and non-goal round sources', () => { + expect(decodeGoalChange(undefined)).toBeUndefined() + expect(decodeGoalChange({ kind: 'other' })).toBeUndefined() + const session = new Session(SessionId('unrelated')) + appendInjection(session, [{ type: 'text', text: 'other' }], { + source: { kind: 'plugin', plugin: 'test' }, + meta: { kind: 'other' }, + }) + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content: [{ type: 'text', text: 'ordinary' }], source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + }) + + it('rejects rounds attributed to another goal', () => { + const change = snapshotChange() + const session = new Session(SessionId('other-goal-round'), oneChange(change)) + appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) + expect(() => foldGoal(session.events)).toThrow('not the next admitted round') + }) + + it('rejects unsupported versions, operations, and top-level shapes', () => { + expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version') + expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid') + expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true, + })).toThrow('clear change has an invalid shape') + }) + + it('rejects invalid create and missing-current mutation sequences', () => { + const base = snapshotChange() + const invalidCreates: GoalSnapshotChangeMeta[] = [ + { ...base, goal: { ...base.goal, revision: 2 } }, + { ...base, goal: { ...base.goal, phase: 'paused' } }, + { ...base, roundsStarted: 1 }, + ] + for (const change of invalidCreates) expect(() => foldGoal(oneChange(change))).toThrow('goal create requires') + + const edit = mutation(base, 'edit', 'active') + expect(() => foldGoal(oneChange(edit))).toThrow('requires a current goal') + const clear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 12, + } + expect(() => foldGoal(oneChange(clear))).toThrow('clear requires a current goal') + + const secondCreate = snapshotChange({ + goal: { ...base.goal, id: GoalId('goal-second') }, + createdAt: 20, + updatedAt: 20, + }) + expect(() => foldPair(base, secondCreate)).toThrow('goal create requires') + }) + + it('rejects stale identity, counters, timestamps, and definition changes', () => { + const base = snapshotChange() + const invalid: GoalSnapshotChangeMeta[] = [ + mutation(base, 'edit', 'active', { goal: { ...base.goal, id: GoalId('goal-wrong'), revision: 2 } }), + mutation(base, 'edit', 'active', { goal: { ...base.goal, revision: 3 } }), + mutation(base, 'edit', 'active', { createdAt: 11 }), + mutation(base, 'edit', 'active', { updatedAt: 9 }), + mutation(base, 'edit', 'active', { roundsStarted: 1 }), + mutation(base, 'pause', 'paused', { + goal: { ...base.goal, revision: 2, phase: 'paused', objective: 'changed illegally' }, + }), + mutation(base, 'pause', 'paused', { + goal: { ...base.goal, revision: 2, phase: 'paused', maxGoalRounds: 3 }, + }), + ] + for (const change of invalid) expect(() => foldPair(base, change)).toThrow() + }) + + it('rejects invalid replayed lifecycle phase transitions', () => { + const base = snapshotChange() + const invalid: GoalSnapshotChangeMeta[] = [ + mutation(base, 'edit', 'paused'), + mutation(base, 'pause', 'active'), + mutation(base, 'resume', 'paused'), + mutation(base, 'complete', 'active'), + mutation(base, 'block', 'active'), + mutation(base, 'mark-usage-limited', 'active'), + mutation(base, 'mark-budget-limited', 'active'), + mutation(base, 'mark-budget-limited', 'budget-limited'), + ] + for (const change of invalid) expect(() => foldPair(base, change)).toThrow() + + const paused = mutation(base, 'pause', 'paused') + const exhausted = mutation(paused, 'resume', 'active', { + roundsStarted: 2, + goal: { ...paused.goal, revision: 3, phase: 'active', maxGoalRounds: 2 }, + }) + const session = new Session(SessionId('exhausted-resume')) + appendChange(session, base) + appendRound(session, base.goal, 1) + appendRound(session, base.goal, 2) + appendChange(session, { ...paused, roundsStarted: 2 }) + appendChange(session, exhausted) + expect(() => foldGoal(session.events)).toThrow('exhausted round budget') + }) + + it('rejects invalid clear continuity and goal id reuse', () => { + const base = snapshotChange() + const staleClear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 3 }, clearedAt: 11, + } + expect(() => foldPair(base, staleClear)).toThrow('advance the current goal') + const earlyClear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 9, + } + expect(() => foldPair(base, earlyClear)).toThrow('timestamp cannot precede') + + const complete = mutation(base, 'complete', 'complete') + const sameCurrentId = snapshotChange({ + goal: { ...base.goal, revision: 1 }, + createdAt: 20, + updatedAt: 20, + }) + const completedSession = new Session(SessionId('reuse-complete')) + appendChange(completedSession, base) + appendChange(completedSession, complete) + appendChange(completedSession, sameCurrentId) + expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one') + + const second = snapshotChange({ + goal: { ...base.goal, id: GoalId('goal-second') }, + createdAt: 20, + updatedAt: 20, + }) + const secondComplete = mutation(second, 'complete', 'complete') + const nonAdjacentReuse = new Session(SessionId('reuse-non-adjacent')) + appendChange(nonAdjacentReuse, base) + appendChange(nonAdjacentReuse, complete) + appendChange(nonAdjacentReuse, second) + appendChange(nonAdjacentReuse, secondComplete) + appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) + expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one') + + const clear: GoalChangeMeta = { + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, + } + const clearedSession = new Session(SessionId('reuse-clear')) + appendChange(clearedSession, base) + appendChange(clearedSession, clear) + appendChange(clearedSession, sameCurrentId) + expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') + }) + + it('rejects goal-source context without matching durable metadata', () => { + const session = new Session(SessionId('goal-source-without-meta')) + const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: [{ type: 'text', text: 'missing' }], source, envelope: 'raw', + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata') + }) + + it('rejects malformed snapshots, refs, counters, and timestamps', () => { + const base = snapshotChange() + const badSnapshots: unknown[] = [ + null, + { ...base.goal, extra: true }, + { ...base.goal, id: '' }, + { ...base.goal, objective: ' ' }, + { ...base.goal, objective: ' padded ' }, + { ...base.goal, phase: 'unknown' }, + { ...base.goal, revision: 0 }, + { ...base.goal, maxGoalRounds: -1 }, + ] + for (const goal of badSnapshots) expect(() => decodeGoalChange({ ...base, goal })).toThrow() + expect(() => decodeGoalChange({ ...base, roundsStarted: -1 })).toThrow('roundsStarted') + expect(() => decodeGoalChange({ ...base, createdAt: -1 })).toThrow('createdAt') + expect(() => decodeGoalChange({ ...base, updatedAt: 9 })).toThrow('cannot precede') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: null, clearedAt: 1, + })).toThrow('tombstone') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: '', revision: 1 }, clearedAt: 1, + })).toThrow('non-empty') + expect(() => decodeGoalChange({ + kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 0 }, clearedAt: 1, + })).toThrow('positive safe integer') + }) + + it('rejects source, content, and envelope drift from the durable metadata', () => { + const change = snapshotChange() + expect(() => foldGoal(oneChange(change, { source: { kind: 'plugin', plugin: 'wrong' } }))).toThrow('mismatched source') + expect(() => foldGoal(oneChange(change, { + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: -1 }, + }))).toThrow('source is invalid') + expect(() => foldGoal(oneChange(change, { content: [{ type: 'text', text: 'wrong' }] }))).toThrow('model-visible content') + const events = oneChange(change) + const context = events.find(event => event.type === 'context/message') + if (context?.type !== 'context/message') throw new Error('expected context') + const altered = structuredClone(events) + const clonedContext = altered.find(event => event.type === 'context/message') + if (clonedContext?.type !== 'context/message') throw new Error('expected cloned context') + delete (clonedContext.data as { envelope?: string }).envelope + expect(() => foldGoal(altered)).toThrow('model-visible content') + }) + + it('folds a clear tombstone after a snapshot', () => { + const change = snapshotChange() + const session = new Session(SessionId('fold-clear'), oneChange(change)) + const clear: GoalChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'clear', + cleared: { id: change.goal.id, revision: 2 }, + clearedAt: 20, + } + const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content: renderGoalChange(clear), source, envelope: 'raw', meta: clear as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + expect(foldGoal(session.events)).toEqual({ + roundsStarted: 0, + lastRef: { id: change.goal.id, revision: 2 }, + }) + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json new file mode 100644 index 0000000000..8a97b2a5e0 --- /dev/null +++ b/packages/goal/goal/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 59a425387b..9ca1e0aab1 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 36d1721945..3c112d59a8 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -8,6 +8,7 @@ import type { Events } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-goal' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -43,6 +44,7 @@ const scopedSubjectResolvers = Object.freeze({ 'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]), 'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]), 'approval/request': adapt<'approval/request'>(args => args[0].agent), + 'goal/changed': adapt<'goal/changed'>(args => args[0]), 'session/created': null, 'session/disposed': null, 'session/event': null, diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index de0046c00e..016e87a969 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -823,6 +823,7 @@ describe('scoped-dispatch invariants', () => { ['agent/turn-stop', [agent, 1]], ['agent/error', [agent, 1, 0, new Error('x')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], + ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 6c5bc479b5..8fefb2d7eb 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7649345fc..4f1fa58dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:* version: link:../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:* + version: link:../packages/goal/goal '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -976,6 +979,34 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/goal: + dependencies: + schemastery: + specifier: ^3.17.2 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/repeat-tool-guard: dependencies: schemastery: @@ -1749,6 +1780,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..289a2e9778 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -68,6 +68,12 @@ export const LINK_MAP: Record = { FsVersion: 'filesystem.md', FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', + CreateGoalRequest: 'goal.md', + CreateGoalSpec: 'goal.md', + EditGoalRequest: 'goal.md', + GoalChanged: 'goal.md', + GoalRef: 'goal.md', + GoalView: 'goal.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0681153330..bd0ecc18df 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -58,6 +58,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'sandbox', 'fs', @@ -169,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-spine-demo'], note: 'The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package.', }, + { + key: 'goals', + pkg: 'goal', + title: 'Same-session goal domain', + mode: 'core', + note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', + }, { key: 'bash', pkg: 'bash', diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index 520375c9ae..f803f164cf 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -21,6 +21,7 @@ const GROUP_ORDER = [ 'util', 'llm', 'core', + 'goal', 'bash', 'fs', 'skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..1092c6c0b0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -27,6 +27,18 @@ { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalSpec", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..ffcb649335 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -49,6 +49,7 @@ "./packages/skill/*/src", "./packages/compact/*/src", "./packages/context/*/src", + "./packages/goal/*/src", "./packages/guard/*/src", "./packages/subagent/*/src", "./packages/tasks/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 36d6c6d462..668541ade3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/goal/goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 59bb4be2ca..156792259e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/goal/goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 3a2b11db3c..5fb3258805 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -54,6 +54,10 @@ "text": "ctx.fs", "link": "/zh-CN/api/harness/fs" }, + { + "text": "ctx.goals", + "link": "/zh-CN/api/harness/goals" + }, { "text": "ctx.llm", "link": "/zh-CN/api/harness/llm" diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index c3afe6b3aa..316af95351 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (42 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (43 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -519,6 +519,32 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L53) +## goal/* + +### goal/changed + +**Mode:** `emit` + +```ts website-api +/** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ +'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void +``` + +Goal mutation accepted by one live agent. The matching context event is already appended or queued in that agent's active tool-batch FIFO. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +- `agent` — agent whose session owns the goal. +- `change` — fresh current projection or clear tombstone. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L166) + ## llm/* ### llm/stream diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md new file mode 100644 index 0000000000..146cb04193 --- /dev/null +++ b/website/zh-CN/api/harness/goals.md @@ -0,0 +1,241 @@ + + +# ctx.goals + +`GoalService` — provided by `@deepseek-ai/dsh-goal`. + +Goal service (`ctx.goals`) backed exclusively by the owning session log. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L97) + +### ctx.goals.resolveCreate(request) + +```ts website-api +/** + * Materialize deployment defaults and validate one create request. + * @param request - objective plus optional caller-selected round cap. + * @returns detached, fully resolved create specification. + */ +resolveCreate(request: CreateGoalRequest): CreateGoalSpec +``` + +Materialize deployment defaults and validate one create request. + +- `request` — objective plus optional caller-selected round cap. + +**Returns** detached, fully resolved create specification. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122) + +### ctx.goals.get(agent) + +```ts website-api +/** + * Read the current goal for one exact live agent. + * @param agent - owning live agent. + * @returns a fresh view or `undefined` when no goal is current. + * @throws {@link GoalError} when the agent is not the registry's live instance. + */ +get(agent: Agent): GoalView | undefined +``` + +Read the current goal for one exact live agent. + +- `agent` — owning live agent. + +**Returns** a fresh view or `undefined` when no goal is current. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) + +### ctx.goals.create(agent, request) + +```ts website-api +/** + * Create and arm a goal. A completed goal may be replaced; every other + * current phase must be cleared or resumed instead. + * @param agent - owning live agent. + * @param request - objective and optional round cap. + * @returns the created live view. + */ +create(agent: Agent, request: CreateGoalRequest): GoalView +``` + +Create and arm a goal. A completed goal may be replaced; every other current phase must be cleared or resumed instead. + +- `agent` — owning live agent. +- `request` — objective and optional round cap. + +**Returns** the created live view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) + +### ctx.goals.edit(agent, ref, request) + +```ts website-api +/** + * Edit objective and/or round cap without changing phase. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @param request - at least one replacement field. + * @returns the edited view. + */ +edit(agent: Agent, ref: GoalRef, request: EditGoalRequest): GoalView +``` + +Edit objective and/or round cap without changing phase. + +- `agent` — owning live agent. +- `ref` — expected current revision. +- `request` — at least one replacement field. + +**Returns** the edited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) + +### ctx.goals.pause(agent, ref) + +```ts website-api +/** + * Pause an active goal and disarm automatic continuation. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the paused view. + */ +pause(agent: Agent, ref: GoalRef): GoalView +``` + +Pause an active goal and disarm automatic continuation. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the paused view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) + +### ctx.goals.resume(agent, ref) + +```ts website-api +/** + * Resume and arm a stopped goal, or rearm an active goal after a + * session-start edge, while its round budget still has capacity. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the active view. + */ +resume(agent: Agent, ref: GoalRef): GoalView +``` + +Resume and arm a stopped goal, or rearm an active goal after a session-start edge, while its round budget still has capacity. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the active view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) + +### ctx.goals.complete(agent, ref) + +```ts website-api +/** + * Mark a current non-complete goal complete and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the completed view. + */ +complete(agent: Agent, ref: GoalRef): GoalView +``` + +Mark a current non-complete goal complete and disarm it. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the completed view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) + +### ctx.goals.block(agent, ref) + +```ts website-api +/** + * Mark an active goal blocked and disarm it. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the blocked view. + */ +block(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal blocked and disarm it. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the blocked view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) + +### ctx.goals.markUsageLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped by an external usage limit. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the usage-limited view. + */ +markUsageLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped by an external usage limit. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the usage-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) + +### ctx.goals.markBudgetLimited(agent, ref) + +```ts website-api +/** + * Mark an active goal stopped at its configured round cap. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the budget-limited view. + */ +markBudgetLimited(agent: Agent, ref: GoalRef): GoalView +``` + +Mark an active goal stopped at its configured round cap. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the budget-limited view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) + +### ctx.goals.clear(agent, ref) + +```ts website-api +/** + * Clear the current goal while retaining a durable tombstone and history. + * @param agent - owning live agent. + * @param ref - expected current revision. + * @returns the tombstone ref whose revision is one past the cleared snapshot. + */ +clear(agent: Agent, ref: GoalRef): GoalRef +``` + +Clear the current goal while retaining a durable tombstone and history. + +- `agent` — owning live agent. +- `ref` — expected current revision. + +**Returns** the tombstone ref whose revision is one past the cleared snapshot. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) From e9940d35cf5bd33b1c434a5c3f0b4c6695df2511 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:56:11 +0800 Subject: [PATCH 031/132] fix(goal): preserve structured domain error codes --- packages/goal/goal/src/runtime.ts | 10 ++++++---- packages/goal/goal/tests/goal.spec.ts | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 9cd656ccae..49184faa8c 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -1,5 +1,6 @@ /** Runtime constructors and protocol constants for the goal domain. */ +import { HarnessError } from '@deepseek-ai/dsh-llm' import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' /** Version of the goal change metadata embedded in `context/message`. */ @@ -15,13 +16,14 @@ export function GoalId(id: string): GoalIdType { } /** Error returned by the goal domain boundary. */ -export class GoalError extends Error { +export class GoalError extends HarnessError { /** * @param message - human-readable rejection reason. * @param code - stable machine-routable classification. */ - constructor(message: string, public readonly code: GoalErrorCode) { - super(message) - this.name = 'GoalError' + // Keep the constructor to narrow HarnessError's string code at this boundary. + // eslint-disable-next-line @typescript-eslint/no-useless-constructor -- type-only narrowing + constructor(message: string, code: GoalErrorCode) { + super(message, code) } } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 03b00dd699..c8aa5bc3e3 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { HarnessError, type ContentBlock, type MessageSource } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import GoalService, { GoalError, @@ -161,6 +161,7 @@ describe('GoalService creation and replay', () => { code: 'GOAL_INVALID_MAX_ROUNDS', })) expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) }) From 0129063ae7d45e0eedbee9b2055b98cfb13e5d95 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:22:10 +0800 Subject: [PATCH 032/132] feat(goal): add model-facing goal tools --- docs/config-catalog.md | 14 + docs/module-graph.md | 8 + docs/rfc/INDEX.md | 1 + ...26-07-19-model-facing-goal-tools.i18n.yaml | 6 + .../2026-07-19-model-facing-goal-tools.md | 63 +++ .../2026-07-19-model-facing-goal-tools.zh.md | 63 +++ docs/tool-catalog.md | 89 +++++ .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ++ .../fixtures/goal/tool-goal/scripted-llm.ts | 88 +++++ examples/package.json | 1 + knip.json | 5 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/goal/README.md | 1 + packages/goal/tool-goal/README.md | 55 +++ packages/goal/tool-goal/package.json | 47 +++ packages/goal/tool-goal/src/authority.ts | 105 +++++ packages/goal/tool-goal/src/index.ts | 222 +++++++++++ .../goal/tool-goal/tests/tool-goal.e2e.ts | 124 ++++++ .../goal/tool-goal/tests/tool-goal.spec.ts | 374 ++++++++++++++++++ packages/goal/tool-goal/tsconfig.json | 39 ++ pnpm-lock.yaml | 37 ++ scripts/gen-tool-catalog.ts | 17 + tsconfig.build.json | 1 + tsconfig.json | 1 + 24 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 packages/goal/tool-goal/README.md create mode 100644 packages/goal/tool-goal/package.json create mode 100644 packages/goal/tool-goal/src/authority.ts create mode 100644 packages/goal/tool-goal/src/index.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts create mode 100644 packages/goal/tool-goal/tests/tool-goal.spec.ts create mode 100644 packages/goal/tool-goal/tsconfig.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6473848ab3..8f95bad05b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1098,6 +1098,20 @@ export interface Config { Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) +## `@deepseek-ai/dsh-tool-goal` + +Requires: `agents` · `goals` · `tools` · `systemPrompt` + +```ts config-catalog +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/module-graph.md b/docs/module-graph.md index 56934f8495..23879ed342 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,6 +30,7 @@ flowchart TD end subgraph group_goal["packages/goal"] pkg_goal["goal"] + pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] pkg_bash["bash"] @@ -261,6 +262,12 @@ flowchart TD pkg_agent_loop --> pkg_session_persistence pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools + pkg_tool_goal --> pkg_agent + pkg_tool_goal --> pkg_goal + pkg_tool_goal --> pkg_llm + pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_system_prompt + pkg_tool_goal --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash pkg_tool_bash --> pkg_home @@ -511,6 +518,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index e6d4ef306b..d3c408f214 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml new file mode 100644 index 0000000000..0d66c0c360 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e +2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md new file mode 100644 index 0000000000..7a207bbc73 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -0,0 +1,63 @@ +# RFC: Model-facing same-session goal tools + +Status: implemented + +English | [中文](2026-07-19-model-facing-goal-tools.zh.md) + +## Problem + +The persisted goal domain deliberately exposes lifecycle verbs to plugins, not directly to a model. A model still needs a small control surface for discovering the current goal, creating one from human intent, and changing its lifecycle. Prompt guidance alone cannot establish who authorized a mutation: a subagent, injected plugin message, stale model turn, or resumed session could all produce the same tool arguments. + +The surface also needs to preserve the separation between durable state and live execution authority. A restored or forked session can replay an active goal but starts disarmed; a later human request such as “continue” should let the model rearm it without requiring a literal command phrase. Conversely, an admitted autonomous goal round must be able to report completion or a persistent blocker without gaining permission to edit, pause, resume, or replace the human objective. + +## Decision + +`@deepseek-ai/dsh-tool-goal` in `packages/goal/tool-goal/` contributes three exclusive tools and one system-prompt policy section over `ctx.goals`: `get_goal`, `create_goal`, and `update_goal`. The names and read-create-update shape follow Codex's compact goal tool surface while the authority rules use this repository's public agent, session, tool, and goal seams. + +### Tools and model contract + +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. + +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. + +All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. + +### Execution authority + +Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. + +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. + +Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. + +### Blocking threshold + +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. + +## Testing + +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. + +## Alternatives considered + +- **Rely on prompt instructions for authority** — rejected because text can guide model judgment but cannot authenticate the live caller, turn, or source event. +- **Expose every goal-service verb as a separate tool** — rejected because a compact read/create/update surface reduces schema cost and keeps compare-and-set behavior uniform. +- **Require exact command phrases** — rejected because natural-language intent, including languages other than English, should be interpreted by the model; execution authority depends on provenance rather than spelling. +- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not. +- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective. +- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal. + +## Consequences + +- Models receive a stable, compact lifecycle surface without direct access to the goal service. +- State-changing calls are constrained by live runtime provenance as well as durable compare-and-set references. +- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives. +- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate. +- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance. + +## Known limitations and deferred work + +- Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. +- These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Human slash-command discovery and rendering are deferred to the command-surface layer. +- A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md new file mode 100644 index 0000000000..46ad7671e1 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -0,0 +1,63 @@ +# RFC:面向模型的同会话目标工具 + +Status: implemented + +[English](2026-07-19-model-facing-goal-tools.md) | 中文 + +## 问题 + +持久目标领域有意把生命周期动词提供给插件,而不直接提供给模型。模型仍然需要一个小型控制面,用于发现当前目标、根据人类意图创建目标并改变其生命周期。仅靠提示词指导无法确定是谁授权了一次变更:子智能体、注入的插件消息、陈旧的模型轮次或恢复后的会话都可能产生相同的工具参数。 + +该表面还需要保持持久状态与实时执行权限之间的分离。恢复或 fork(派生)后的会话可以回放活跃目标,但初始处于未激活状态;后续人类提出“继续”之类的请求时,模型应能重新激活目标,而无需用户使用字面命令。相反,已接纳的自主目标回合必须能够报告完成或持续阻塞,却不能因此获得编辑、暂停、恢复或替换人类目标的权限。 + +## 决策 + +位于 `packages/goal/tool-goal/` 的 `@deepseek-ai/dsh-tool-goal` 在 `ctx.goals` 之上贡献三个独占工具和一个系统提示词策略段:`get_goal`、`create_goal` 与 `update_goal`。工具名称和读取—创建—更新形态遵循 Codex 的紧凑目标工具表面,而权限规则使用本仓库公共的 agent(智能体)、会话、工具与目标接缝。 + +### 工具与模型契约 + +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 + +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 + +三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 + +### 执行权限 + +每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 + +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 + +完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 + +### 阻塞阈值 + +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 + +## 测试 + +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 + +## 考虑过的替代方案 + +- **依赖提示词指令实施权限**——不予采纳,因为文本可以指导模型判断,却不能认证实时调用者、轮次或来源事件。 +- **把每个目标服务动词分别暴露为工具**——不予采纳,因为紧凑的读取/创建/更新表面可以降低模式成本,并保持统一的比较并交换行为。 +- **要求精确命令短语**——不予采纳,因为自然语言意图(包括英语以外的语言)应由模型解释;执行权限取决于来源,而不是拼写。 +- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。 +- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。 +- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。 + +## 后果 + +- 模型获得稳定而紧凑的生命周期表面,无需直接访问目标服务。 +- 改变状态的调用同时受到实时运行时来源与持久比较并交换引用的约束。 +- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。 +- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。 +- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。 + +## 已知限制与延期工作 + +- 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 +- 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 面向人类的斜杠命令发现与渲染延期到命令表面层。 +- 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index dfb97deef3..64fd5e692d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -21,6 +21,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | +| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -393,6 +394,94 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. +## `@deepseek-ai/dsh-tool-goal` + +### `create_goal` + +Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. + +```json +{ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `get_goal` + +Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +### `update_goal` + +Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. + +```json +{ + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] +} +``` + +Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/index.ts) + +create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml new file mode 100644 index 0000000000..fa63160399 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml @@ -0,0 +1,26 @@ +# Test-only composition: drive all three goal tools through a real root agent. +- id: scripted-llm + name: './scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + config: + defaultMaxGoalRounds: 11 + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: goal-script + model: goal-script + persona: 'Execute the deterministic goal-tool composition test.' + welcome: 'goal-tools e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts new file mode 100644 index 0000000000..39498f8e39 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -0,0 +1,88 @@ +/** Deterministic adapter that creates, reads, then pauses one goal. */ + +import type { Context } from 'cordis' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' + +interface GoalState { + readonly id: string + readonly revision: number +} + +/** Text from the latest ordinary user message, excluding raw goal-state context. */ +function latestPrompt(messages: readonly Message[]): { index: number; text: string } { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role !== 'user') continue + const text = message.content + .filter(block => block.type === 'text' && !block.text.startsWith('')) + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text.length > 0) return { index, text } + } + return { index: -1, text: '' } +} + +/** Parse the latest domain snapshot rendered into history. */ +function latestGoal(messages: readonly Message[]): GoalState | undefined { + for (const message of [...messages].reverse()) { + for (const block of [...message.content].reverse()) { + if (block.type !== 'text' || !block.text.startsWith('')) continue + const json = block.text.slice(''.length, -''.length) + const value = JSON.parse(json) as { goal?: GoalState } + if (value.goal !== undefined) return value.goal + } + } + return undefined +} + +/** Names of tool calls recorded after the latest ordinary prompt. */ +function callsAfter(messages: readonly Message[], index: number): string[] { + return messages.slice(index + 1).flatMap(message => message.content) + .filter(block => block.type === 'tool-call') + .map(block => block.type === 'tool-call' ? block.name : '') +} + +/** Emit one tool-call response. */ +async function* toolCall(name: string, args: object): AsyncIterable { + const id = CallId(`call-${name}`) + const raw = JSON.stringify(args) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } +} + +/** Emit one terminal text response. */ +async function* textReply(text: string): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } +} + +class GoalScriptAdapter extends LlmAdapter { + override stream(options: GenerateOptions): AsyncIterable { + const prompt = latestPrompt(options.messages) + const calls = callsAfter(options.messages, prompt.index) + if (prompt.text === 'start' && !calls.includes('create_goal')) { + return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) + } + if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) + if (prompt.text === 'start') return textReply('GOAL CREATED') + if (prompt.text === 'pause' && !calls.includes('update_goal')) { + const goal = latestGoal(options.messages) + if (goal === undefined) throw new Error('scripted goal state missing') + return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) + } + if (prompt.text === 'pause') return textReply('GOAL PAUSED') + return textReply('UNEXPECTED PROMPT') + } +} + +export const name = 'goal-tool-scripted-llm' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) +} diff --git a/examples/package.json b/examples/package.json index 3a3815582a..b1c8ac52cd 100644 --- a/examples/package.json +++ b/examples/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/knip.json b/knip.json index 6f04363494..eb4eff3a9f 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", + "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", @@ -71,6 +72,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/tool-goal": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3097f1abeb..c6b7f9a804 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/goal/README.md b/packages/goal/README.md index 45f1b4f135..1ab912b3c7 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,5 +5,6 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | +| `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md new file mode 100644 index 0000000000..86def6b65e --- /dev/null +++ b/packages/goal/tool-goal/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-goal + +The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal`, `create_goal`, and `update_goal`. The [goal-tool RFC](../../../docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md) owns the authority split and Codex-shaped UX. + +## Tools + +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. + +All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. + +## Authority + +Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. + +Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. + +## Config + +```yaml +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + config: + blockedAfterConsecutiveRounds: 3 +``` + +The value must be a positive safe integer. It supplies both the hard lower bound on model self-blocking and the number named in model guidance. + +## Model Experience + +### System prompt + +**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. + +**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### Goal policy + +```markdown +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +``` + +### Tool schemas and results + +**What the model sees**: The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. + +**Token effect**: Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. + +## Known Limitations and Deferred Work + +- **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. +- **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. +- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json new file mode 100644 index 0000000000..28cd6839f8 --- /dev/null +++ b/packages/goal/tool-goal/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-tool-goal", + "description": "Model-facing same-session goal tools with execution-time authority checks", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts new file mode 100644 index 0000000000..a2238aab9c --- /dev/null +++ b/packages/goal/tool-goal/src/authority.ts @@ -0,0 +1,105 @@ +/** Execution-time authority checks for the model-facing goal tools. */ + +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +type TurnStartEvent = Extract + +/** Current open turn plus the events accepted after its start boundary. */ +export interface GoalToolExecution { + readonly agent: Agent + readonly start: TurnStartEvent + readonly events: readonly SessionEvent[] +} + +/** Hard authority granted to one state-changing call. */ +export type GoalToolAuthority = + | { readonly kind: 'direct-human' } + | { readonly kind: 'goal-round'; readonly goal: GoalView } + +/** Throw one structured tool-policy failure. */ +function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { + throw new HarnessError(message, code) +} + +/** Locate the open turn enclosing a model tool call. */ +function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + const boundary = events[index] + if (boundary?.type === 'turn/end') { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + } + if (boundary?.type === 'turn/start') { + return { start: boundary, events: events.slice(index + 1) } + } + } + return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') +} + +/** + * Resolve and authenticate the calling agent and its driver boundary. + * @param ctx - Context carrying the live agent registry. + * @param exec - Tool execution metadata supplied by the registry. + * @returns The authenticated agent and its current turn window. + */ +export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') + } + if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + return { agent, ...openTurn(agent) } +} + +/** Whether an accepted human message appears in the current root-agent turn. */ +function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { + if (!ctx.agents.roots().includes(execution.agent)) return false + return execution.events.some(event => + (event.type === 'user/message' || event.type === 'steering/message') + && event.data.source.kind === 'user') +} + +/** Whether this turn is the current goal's exact admitted round. */ +function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { + return execution.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal' + && event.data.source.goalId === goal.id + && event.data.source.revision === goal.revision + && event.data.source.round === goal.roundsStarted) +} + +/** + * Require authority originating in a human message accepted by a runtime root. + * @param ctx - Context carrying the live agent graph. + * @param execution - Authenticated current tool execution. + */ +export function requireDirectHuman(ctx: Context, execution: GoalToolExecution): void { + if (hasDirectHumanInput(ctx, execution)) return + reject('this goal operation requires a direct human turn on a top-level agent') +} + +/** + * Resolve completion authority from either direct human input or the exact goal round. + * @param ctx - Context carrying live agents and goal state. + * @param execution - Authenticated current tool execution. + * @returns The direct-human or exact-goal-round authority grant. + */ +export function completionAuthority(ctx: Context, execution: GoalToolExecution): GoalToolAuthority { + if (hasDirectHumanInput(ctx, execution)) return { kind: 'direct-human' } + const goal = ctx.goals.get(execution.agent) + if (goal !== undefined && isMatchingGoalRound(execution, goal)) { + return { kind: 'goal-round', goal } + } + return reject('complete and blocked require a direct human turn or the current goal round') +} diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts new file mode 100644 index 0000000000..9bfa51095f --- /dev/null +++ b/packages/goal/tool-goal/src/index.ts @@ -0,0 +1,222 @@ +/** + * Model-facing `get_goal`, `create_goal`, and `update_goal` tools over the + * persisted same-session goal domain. + * @module @deepseek-ai/dsh-tool-goal + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { + completionAuthority, + goalToolExecution, + requireDirectHuman, +} from './authority.ts' + +export const name = 'tool-goal' +export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] + +/** Model policy and hard lower bounds for goal-state updates. */ +export interface Config { + /** Minimum admitted goal rounds before the model may self-report `blocked`. */ + blockedAfterConsecutiveRounds?: number +} + +/** Schemastery config for the goal-tool policy. */ +export const Config: z = z.object({ + blockedAfterConsecutiveRounds: z.number().step(1).min(1).default(3), +}) + +/** Fully materialized tool policy. */ +interface ResolvedConfig { + readonly blockedAfterConsecutiveRounds: number +} + +type UpdateAction = 'edit' | 'pause' | 'resume' | 'complete' | 'blocked' + +const UPDATE_ACTIONS: UpdateAction[] = ['edit', 'pause', 'resume', 'complete', 'blocked'] + +const CREATE_DESCRIPTION = + 'Create one persisted same-session completion goal when the current direct human request ' + + 'is a long-running objective that should continue across autonomous goal rounds. You may ' + + 'infer that intent without requiring the user to say "create a goal". Do not use this for ' + + 'trivial single-turn work. Execution rejects non-human and subagent authority.' + +const GET_DESCRIPTION = + 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' + + 'round count, cap, and live process-local activation. Call this before updating a goal.' + +/** Render policy guidance with its deployment-selected blocked threshold. */ +function guidance(blockedAfter: number): string { + return 'Use goal tools for one long-running completion objective in the current session. ' + + 'create_goal may infer goal intent from a direct human request in any language; do not ' + + 'create a goal for routine single-turn work. Call get_goal before update_goal and copy its ' + + 'exact goal_id and revision. After session resume or fork, an active goal is disarmed: when ' + + 'a human asks to continue or resume in any wording or language, use update_goal action ' + + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` + + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' +} + +/** Validate config even when apply is called directly outside Loader normalization. */ +function resolveConfig(config: Config): ResolvedConfig { + const blockedAfter = config.blockedAfterConsecutiveRounds ?? 3 + if (!Number.isSafeInteger(blockedAfter) || blockedAfter < 1) { + throw new TypeError('blockedAfterConsecutiveRounds must be a positive safe integer') + } + return { blockedAfterConsecutiveRounds: blockedAfter } +} + +/** Build the exact compare-and-set ref from model arguments. */ +function goalRef(goalId: string, revision: number): GoalRef { + if (goalId.length === 0 || goalId !== goalId.trim() + || !Number.isSafeInteger(revision) || revision < 1) { + throw new HarnessError( + 'goal_id must be non-empty and revision must be a positive safe integer', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + return { id: GoalId(goalId), revision } +} + +/** Stable compact model result; activation is an observation, not replay state. */ +function renderGoal(goal: GoalView | undefined): string { + if (goal === undefined) return JSON.stringify({ goal: null }) + return JSON.stringify({ + goal: { + id: goal.id, + revision: goal.revision, + objective: goal.objective, + phase: goal.phase, + roundsStarted: goal.roundsStarted, + maxGoalRounds: goal.maxGoalRounds, + }, + activation: goal.activation, + }) +} + +/** Generic, args-only pending presentation shared by the goal tools. */ +function present(title: string, kind: 'read' | 'other', rawInput?: unknown): GenericCallView { + return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } +} + +/** Register the three Codex-shaped goal tools and their shared policy section. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:goal', + order: 114, + text: guidance(resolved.blockedAfterConsecutiveRounds), + }) + + ctx.tools.register(defineTool({ + name: 'get_goal', + description: GET_DESCRIPTION, + parameters: {}, + execute(_args, exec) { + const execution = goalToolExecution(ctx, exec) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.get(execution.agent)), + }]) + }, + presentCall: () => present('Read current goal', 'read'), + })) + + ctx.tools.register(defineTool({ + name: 'create_goal', + description: CREATE_DESCRIPTION, + parameters: { + objective: { + type: 'string', + required: true, + description: 'The concrete completion objective inferred from the direct human request.', + }, + max_goal_rounds: { + type: 'number', + description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + requireDirectHuman(ctx, execution) + const goal = ctx.goals.create(execution.agent, { + objective: args.objective, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + }) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present('Create goal', 'other', args.objective), + })) + + ctx.tools.register(defineTool({ + name: 'update_goal', + description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' + + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'responsible for judging that the same condition persisted across those rounds.', + parameters: { + goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, + revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, + action: { + type: 'string', + required: true, + enum: UPDATE_ACTIONS, + description: 'edit | pause | resume | complete | blocked', + }, + objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, + max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + }, + execute(args, exec) { + const execution = goalToolExecution(ctx, exec) + const ref = goalRef(args.goal_id, args.revision) + const replacements = { + ...args.objective === undefined ? {} : { objective: args.objective }, + ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, + } + if (args.action === 'edit') { + requireDirectHuman(ctx, execution) + return Promise.resolve([{ + type: 'text', + text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + }]) + } + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) + if (args.action === 'blocked' && authority.kind === 'goal-round' + && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { + throw new HarnessError( + `blocked requires at least ${resolved.blockedAfterConsecutiveRounds} consecutive goal rounds; ` + + `current round is ${authority.goal.roundsStarted}`, + 'GOAL_TOOL_BLOCK_THRESHOLD', + ) + } + const goal = args.action === 'complete' + ? ctx.goals.complete(execution.agent, ref) + : ctx.goals.block(execution.agent, ref) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + }, + presentCall: args => present( + `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, + 'other', + args.objective ?? args.goal_id, + ), + })) +} diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts new file mode 100644 index 0000000000..037b91d18d --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -0,0 +1,124 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { decodeGoalChange } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runComposition(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let pauseSent = false + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { + pauseSent = true + proc.stdin.write('pause\n') + } + if (!inputClosed && stdout.includes('GOAL PAUSED')) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error( + `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, + )) + }, PROCESS_TIMEOUT_MS) + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('start\n') + }) +} + +describe('goal tools through a real Loader, app, and stdio process', () => { + it('creates, reads, and pauses one root goal with durable tool and state records', async () => { + const { stdout, stderr } = await runComposition() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-tools e2e ready.') + expect(stdout).toContain('GOAL CREATED') + expect(stdout).toContain('GOAL PAUSED') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(3) + expect(results.every(event => !event.data.isError)).toBe(true) + + const changes = events + .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') + .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) + expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) + expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) + expect(JSON.stringify(changes)).not.toContain('activation') + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).toContain('infer goal intent') + expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts new file mode 100644 index 0000000000..c5f1a00593 --- /dev/null +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as toolGoal from '@deepseek-ai/dsh-tool-goal' + +interface StubAgent { + readonly agent: Agent + readonly session: Session + setStatus(status: AgentStatus): void +} + +/** Build one registry-compatible live agent whose injections append in place. */ +function stubAgent(rawId: string): StubAgent { + const session = new Session(SessionId(rawId)) + let status: AgentStatus = 'running' + const agent: Agent = { + id: session.id, + options: {}, + session, + get status() { return status }, + ctx: new Context(), + send() {}, + steer() {}, + inject(content: ContentBlock[], options?: InjectOptions) { + const source = options?.source ?? { kind: 'user' } + session.append('context/message', { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + return { agent, session, setStatus(value) { status = value } } +} + +/** Open one message-triggered turn with its accepted model-visible input. */ +function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { + const turn = stub.session.events + .filter(event => event.type === 'turn/start') + .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 + stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + stub.session.append('user/message', { + content: [{ type: 'text', text }], + source, + }, { surfaceOp: 'append' }) + return turn +} + +/** Close the currently open test turn. */ +function closeTurn(stub: StubAgent, turn: number): void { + stub.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +async function harness(config: toolGoal.Config = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + const fiber = await ctx.plugin(toolGoal, config) + const root = stubAgent(`goal-tool-root-${Math.random()}`) + ctx.agents.register(root.agent) + return { ctx, fiber, root } +} + +/** Execute one registered tool under an optional driver initiator. */ +async function execute( + ctx: Context, + name: string, + args: unknown, + agent?: Agent, + initiator: Agent | undefined = agent, +): Promise { + const run = () => ctx.tools.execute({ + callId: CallId(`call-${Math.random()}`), + name, + arguments: args, + ...agent === undefined ? {} : { agent }, + }) + return initiator === undefined ? run() : ctx.agents.withInitiator(initiator, run) +} + +/** Parse the compact JSON returned by a successful goal tool. */ +function resultJson(result: ToolExecutionResult): Record { + expect(result.isError).toBe(false) + const block = result.content[0] + if (block?.type !== 'text') throw new Error('expected text tool result') + return JSON.parse(block.text) as Record +} + +/** Read the returned goal sub-object. */ +function resultGoal(result: ToolExecutionResult): Record { + const goal = resultJson(result)['goal'] + if (typeof goal !== 'object' || goal === null) throw new Error('expected returned goal') + return goal as Record +} + +describe('goal tool registration and presentation', () => { + it('registers three exclusive tools plus configured guidance and disposes all contributions', async () => { + const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 }) + expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) + .toEqual(['create_goal', 'get_goal', 'update_goal']) + for (const name of ['create_goal', 'get_goal', 'update_goal']) { + expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} })) + .toEqual({ kind: 'exclusive' }) + } + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('infer goal intent') + expect(section?.text).toContain('at least 5 consecutive goal rounds') + + await fiber.dispose() + expect(ctx.tools.get('get_goal')).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.some(item => item.name === 'tool:goal')).toBe(false) + }) + + it('uses args-only generic render intent and soft-fails malformed replay args', async () => { + const { ctx } = await harness() + expect(ctx.tools.get('get_goal')?.presentCall?.({})).toEqual({ + card: 'generic', title: 'Read current goal', kind: 'read', + }) + expect(ctx.tools.get('create_goal')?.presentCall?.({ objective: 'ship' })).toEqual({ + card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', + }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'blocked', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ + goal_id: 'goal-1', revision: 2, action: 'resume', + })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) + expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined() + }) + + it('has the Loader-safe namespace export shape', () => { + expect('default' in toolGoal).toBe(false) + expect(toolGoal.name).toBe('tool-goal') + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) + }) + + it('fails invalid direct config before registering anything', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + expect(() => { + toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) + }).toThrow( + 'blockedAfterConsecutiveRounds must be a positive safe integer', + ) + expect(ctx.tools.get('get_goal')).toBeUndefined() + }) + + it('resolves the direct-apply default before registration', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ToolRegistry) + await ctx.plugin(GoalService) + toolGoal.apply(ctx, {}) + const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') + expect(section?.text).toContain('at least 3 consecutive goal rounds') + }) +}) + +describe('goal tool execution authority', () => { + it('lets a root model infer create intent from its accepted human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }, '请持续工作直到这个功能完成') + const result = await execute(ctx, 'create_goal', { + objective: 'Finish the feature', max_goal_rounds: 9, + }, root.agent) + expect(resultGoal(result)).toMatchObject({ + objective: 'Finish the feature', revision: 1, phase: 'active', maxGoalRounds: 9, + }) + expect(resultJson(result)['activation']).toBe('armed') + expect(ctx.goals.get(root.agent)?.objective).toBe('Finish the feature') + }) + + it('rejects agentless, driverless, non-human, and live-child creation', async () => { + const { ctx, root } = await harness() + const agentless = await execute(ctx, 'get_goal', {}) + expect(agentless.error?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') + + openTurn(root, { kind: 'user' }) + const driverless = await ctx.tools.execute({ + callId: CallId('call-driverless'), + name: 'get_goal', + arguments: {}, + agent: root.agent, + }) + expect(driverless.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + closeTurn(root, 1) + + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const nonHuman = await execute(ctx, 'create_goal', { objective: 'forged' }, root.agent) + expect(nonHuman.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + closeTurn(root, 2) + + const child = stubAgent('goal-tool-child') + ctx.agents.enter(child.agent, root.agent) + ctx.agents.announce(child.agent) + openTurn(child, { kind: 'user' }) + const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent) + expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('rejects calls before a turn and after its end boundary', async () => { + const { ctx, root } = await harness() + const before = await execute(ctx, 'get_goal', {}, root.agent) + expect(before.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + const turn = openTurn(root, { kind: 'user' }) + closeTurn(root, turn) + const after = await execute(ctx, 'get_goal', {}, root.agent) + expect(after.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('rejects terminal reporting without human input or a current goal round', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'plugin', plugin: 'test' }) + const result = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'complete', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + }) + + it('accepts direct human steering in a goal-sourced root turn', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'steer me' }) + closeTurn(root, humanTurn) + const round = openTurn(root, { + kind: 'goal', goalId: created.id, revision: created.revision, round: 1, + }) + root.session.append('steering/message', { + turn: round, + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused', revision: 2 }) + }) + + it('rejects an initiator different from exec.agent', async () => { + const { ctx, root } = await harness() + const other = stubAgent('goal-tool-other') + ctx.agents.register(other.agent) + openTurn(other, { kind: 'user' }) + const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) +}) + +describe('goal tool state transitions', () => { + it('reads null, then edits, pauses, and resumes by exact revision in one human turn', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + expect(resultJson(await execute(ctx, 'get_goal', {}, root.agent))).toEqual({ goal: null }) + let goal = resultGoal(await execute(ctx, 'create_goal', { objective: 'old' }, root.agent)) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'edit', + objective: 'new', max_goal_rounds: 8, + }, root.agent)) + expect(goal).toMatchObject({ objective: 'new', revision: 2, maxGoalRounds: 8 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'pause', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'paused', revision: 3 }) + goal = resultGoal(await execute(ctx, 'update_goal', { + goal_id: goal['id'], revision: goal['revision'], action: 'resume', + }, root.agent)) + expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + }) + + it('rearms a restored active goal only after a new direct human prompt', async () => { + const { ctx, root } = await harness() + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'continue later' }) + closeTurn(root, turn) + agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') + turn = openTurn(root, { kind: 'user' }, '继续') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, root.agent) + expect(resultGoal(resumed)).toMatchObject({ phase: 'active', revision: 2 }) + expect(resultJson(resumed)['activation']).toBe('armed') + closeTurn(root, turn) + }) + + it('returns structured domain and conditional-argument failures', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const invalidCreate = await execute(ctx, 'create_goal', { objective: ' ' }, root.agent) + expect(invalidCreate.error?.code).toBe('GOAL_INVALID_OBJECTIVE') + const created = ctx.goals.create(root.agent, { objective: 'valid' }) + const replacement = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'pause', + objective: 'not valid for pause', + }, root.agent) + expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const malformedRef = await execute(ctx, 'update_goal', { + goal_id: '', revision: 0, action: 'edit', objective: 'x', + }, root.agent) + expect(malformedRef.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + }) + + it('allows exact goal rounds to complete but not edit or pause', async () => { + const { ctx, root } = await harness() + const humanTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'round-owned' }) + closeTurn(root, humanTurn) + openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1 }) + const edit = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'edit', objective: 'forbidden', + }, root.agent) + expect(edit.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete', revision: 2, roundsStarted: 1 }) + }) + + it('enforces the configured model self-block lower bound across admitted rounds', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 3 }) + let turn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'blocked eventually' }) + closeTurn(root, turn) + const ref: GoalRef = { id: GoalId(created.id), revision: created.revision } + + for (let round = 1; round <= 2; round += 1) { + turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) + const result = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') + closeTurn(root, turn) + } + openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: ref.id, revision: ref.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + }) + + it('lets direct human authority block before the model threshold', async () => { + const { ctx, root } = await harness({ blockedAfterConsecutiveRounds: 9 }) + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'human stop' }) + const blocked = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + }) +}) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json new file mode 100644 index 0000000000..d5c34d4e29 --- /dev/null +++ b/packages/goal/tool-goal/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../goal" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1fa58dce..e1d3bd1fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:* version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:* + version: link:../packages/goal/tool-goal '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -1007,6 +1010,40 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/tool-goal: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/guard/repeat-tool-guard: dependencies: schemastery: diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8606d30895..1921dbf27c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -10,6 +10,8 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' @@ -28,6 +30,7 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -178,6 +181,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.', }, + { + pkg: '@deepseek-ai/dsh-tool-goal', + dir: 'tool-goal', + source: 'packages/goal/tool-goal/src/index.ts', + requires: ['ctx.tools', 'ctx.agents', 'ctx.goals', 'ctx.systemPrompt', 'a calling Agent in an authorized open turn'], + writes: ['tool/call', 'context/message goal snapshot for mutations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + await ctx.plugin(ToolGoal) + }, + note: + 'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/tsconfig.build.json b/tsconfig.build.json index 668541ade3..db84df1542 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 156792259e..27b0e1d184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, + { "path": "./packages/goal/tool-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, From fbefc1d65b3cc20936bb4fd0a079e5a41a8315d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:42:17 +0800 Subject: [PATCH 033/132] fix(goal): stop terminal goal tool turns --- docs/config-catalog.md | 2 +- docs/event-producer-consumer.md | 2 +- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +-- .../2026-07-19-model-facing-goal-tools.md | 2 ++ .../2026-07-19-model-facing-goal-tools.zh.md | 2 ++ packages/goal/tool-goal/README.md | 2 ++ packages/goal/tool-goal/src/index.ts | 28 ++++++++++++++++++- .../goal/tool-goal/tests/tool-goal.spec.ts | 13 +++++++++ 8 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8f95bad05b..64cbb27222 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1110,7 +1110,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..d48349cdf0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 0d66c0c360..e4e0fbf855 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 7a207bbc73e13ca5111d73ee2c58cf05fbe6387e -2026-07-19-model-facing-goal-tools.zh.md: 46ad7671e1ef2a6856df6d1ec893896aab9760b5 +2026-07-19-model-facing-goal-tools.md: cb823aa944d69228005884ac73cc99b67fa00dfb +2026-07-19-model-facing-goal-tools.zh.md: f93ffa3a2eed602d8c7d98faccf09c9e46924f28 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md index 7a207bbc73..cb823aa944 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,6 +22,8 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. +A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. + ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 46ad7671e1..f93ffa3a2e 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,6 +22,8 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 +成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 + ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 86def6b65e..a3987e32e1 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,6 +10,8 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. +A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. + ## Authority Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9bfa51095f..29068a4afa 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -6,6 +6,7 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -17,6 +18,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import type { GoalToolExecution } from './authority.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -105,9 +107,28 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } +/** Remember whether one successful mutation makes this turn terminal. */ +function observeMutation( + terminalTurns: WeakMap, + execution: GoalToolExecution, + goal: GoalView, +): void { + if (goal.phase === 'active' && goal.activation === 'armed') { + terminalTurns.delete(execution.agent) + return + } + terminalTurns.set(execution.agent, execution.start.data.turn) +} + /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + const terminalTurns = new WeakMap() + ctx.on('agent/turn-stop', (agent, turn) => { + if (terminalTurns.get(agent) !== turn) return undefined + terminalTurns.delete(agent) + return { action: 'stop' } + }) ctx.systemPrompt.section({ name: 'tool:goal', order: 114, @@ -149,6 +170,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -181,9 +203,11 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + const goal = ctx.goals.edit(execution.agent, ref, replacements) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', - text: renderGoal(ctx.goals.edit(execution.agent, ref, replacements)), + text: renderGoal(goal), }]) } if (args.objective !== undefined || args.max_goal_rounds !== undefined) { @@ -197,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -211,6 +236,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) + observeMutation(terminalTurns, execution, goal) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index c5f1a00593..2532b16a15 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -287,6 +287,19 @@ describe('goal tool state transitions', () => { goal_id: goal['id'], revision: goal['revision'], action: 'resume', }, root.agent)) expect(goal).toMatchObject({ phase: 'active', revision: 4 }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + }) + + it('stops the current turn after a successful stopped-state mutation', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) + const paused = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'pause', + }, root.agent) + expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { From d49808c6dd60c24e05199c375a1483898de0d9fb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 20:14:32 +0800 Subject: [PATCH 034/132] fix(tools): avoid retaining result executions --- packages/core/tools/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 223ae863d7..239d5210bb 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -958,8 +958,9 @@ export class ToolRegistry extends Service { // Freeze the remaining mutable signal slot before observers receive the // shared WeakMap-keyable execution object. Object.freeze(exec) + const { name: toolName, callId } = exec const reportFailure = (error: unknown): void => { - this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`) + this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`) } const callbacks = this.ctx.events.dispatch('emit', [ scopeTarget(this, exec.agent), 'tools/result', exec, result, From 25555c9cfc33b0e7eb0d656cb3ae011d6e7bfdab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:30:43 +0800 Subject: [PATCH 035/132] feat(goal): drive same-session goal rounds --- docs/architecture.md | 2 +- docs/config-catalog.md | 1 + docs/cordis-catalog/events.md | 51 +- docs/cordis-catalog/services.md | 9 + docs/core-data-structures/core.md | 7 +- docs/event-producer-consumer.md | 35 +- docs/module-graph.md | 6 + docs/rfc/INDEX.md | 1 + ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 4 +- ...9-persisted-same-session-goal-domain.zh.md | 4 +- ...9-same-session-goal-round-driver.i18n.yaml | 6 + ...26-07-19-same-session-goal-round-driver.md | 100 +++ ...07-19-same-session-goal-round-driver.zh.md | 100 +++ .../fixtures/goal/goal-session/cordis.yml | 25 + .../goal/goal-session/scripted-llm.ts | 104 +++ examples/package.json | 1 + knip.json | 5 + .../cordis/tool-cordis/src/api-catalog.ts | 11 + packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 9 +- packages/core/agent-loop/tests/cancel.spec.ts | 29 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/types.ts | 17 +- packages/goal/README.md | 1 + packages/goal/goal-session/README.md | 62 ++ packages/goal/goal-session/package.json | 43 ++ packages/goal/goal-session/src/index.ts | 443 ++++++++++++ packages/goal/goal-session/src/outcome.ts | 48 ++ packages/goal/goal-session/src/prompt.ts | 26 + .../goal-session/tests/goal-session.e2e.ts | 138 ++++ .../goal-session/tests/goal-session.spec.ts | 648 ++++++++++++++++++ packages/goal/goal-session/tsconfig.json | 30 + packages/goal/goal/README.md | 4 +- packages/goal/goal/src/index.ts | 15 + packages/goal/goal/tests/goal.spec.ts | 14 + .../invariants/src/scoped-events.generated.ts | 1 + pnpm-lock.yaml | 36 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/zh-CN/api/harness/events.md | 56 +- website/zh-CN/api/harness/goals.md | 39 +- 42 files changed, 2065 insertions(+), 78 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md create mode 100644 examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml create mode 100644 examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts create mode 100644 packages/goal/goal-session/README.md create mode 100644 packages/goal/goal-session/package.json create mode 100644 packages/goal/goal-session/src/index.ts create mode 100644 packages/goal/goal-session/src/outcome.ts create mode 100644 packages/goal/goal-session/src/prompt.ts create mode 100644 packages/goal/goal-session/tests/goal-session.e2e.ts create mode 100644 packages/goal/goal-session/tests/goal-session.spec.ts create mode 100644 packages/goal/goal-session/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index 2c6cd9c9ea..d0cc86895a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,7 @@ Tool-time context—including async `agent.inject()` notices and post-tool `addi The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success. -Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. +Other failures use `agent/error`. Cancellation beats recovery; undispatched calls get synthetic `ABORTED` results. Effective `cancel()` emits `agent/cancel-requested` before queue clearing or abort; observers cannot veto it, and idle calls emit nothing. Disposal awaits quiescence. Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64cbb27222..7b3a700742 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1486,6 +1486,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) +- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 0f5a4650cd..2ab18f89b8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -13,6 +13,27 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ## `agent/*` +### `agent/cancel-requested` — emit + +Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. + +```ts cordis-catalog +/** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:186`](../../packages/core/agent/src/types.ts) + ### `agent/created` — emit A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. @@ -33,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +119,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +142,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +163,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:225`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +184,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:176`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +207,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +232,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +258,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +300,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +322,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:263`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +343,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +364,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 934086dd97..de04e18628 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -499,6 +499,15 @@ resolveCreate(request: CreateGoalRequest): CreateGoalSpec */ get(agent: Agent): GoalView | undefined +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 25ded90954..76d94cec79 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -386,9 +386,10 @@ interface Agent { /** * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * the active step. An effective call first emits `agent/cancel-requested` with + * the resolved reason. The supplied reason is preserved across pre-step and + * active cancellation windows, and `whenIdle()` resolves after cancellation + * reaches quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d48349cdf0..96bac92e01 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,30 +8,31 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:186`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:225`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:176`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:199`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:263`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 23879ed342..71f69f0594 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -30,6 +30,7 @@ flowchart TD end subgraph group_goal["packages/goal"] pkg_goal["goal"] + pkg_goal_session["goal-session"] pkg_tool_goal["tool-goal"] end subgraph group_bash["packages/bash"] @@ -248,6 +249,10 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_goal_session --> pkg_agent + pkg_goal_session --> pkg_goal + pkg_goal_session --> pkg_llm + pkg_goal_session --> pkg_session pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox @@ -515,6 +520,7 @@ flowchart TD | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index d3c408f214..72e60ffca7 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -91,6 +91,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | +| [Same-session goal-round driver](implemented/feature/2026-07-19-same-session-goal-round-driver.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 771afcf5bf..4127a21d0f 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: ebf6168a4d8552d40e22db309f953f6f273511a9 -2026-07-19-persisted-same-session-goal-domain.zh.md: 5fd35ee8faffc1c53d257bee48f36d70838fdee1 +2026-07-19-persisted-same-session-goal-domain.md: eab031a5ad174c9e2323576bb69919ac8e853958 +2026-07-19-persisted-same-session-goal-domain.zh.md: 606268d80a0d554418dcac3cc50e84cae6e324b1 diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index ebf6168a4d..eab031a5ad 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -28,7 +28,7 @@ When `Agent.inject()` defers a mutation inside an active tool batch, the service At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. -A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. +A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. `GoalService.disarm(agent)` also lets a lifecycle owner remove process-local authority without a session event, revision change, or `goal/changed` notification. Resume, fork, and continuation-driver replacement therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. ### Service boundary @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 5fd35ee8fa..606268d80a 100644 --- a/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -28,7 +28,7 @@ Status: implemented 最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 -从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 +从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,恢复、fork 和继续执行驱动器替换都会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 ### 服务边界 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动与生命周期所有者解除激活、活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml new file mode 100644 index 0000000000..870c10b15d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-same-session-goal-round-driver.md: 886ea981056071e64f0bc037d969c5e33486a806 +2026-07-19-same-session-goal-round-driver.zh.md: be063c8a8b802a1e67c5fc84f16695a3d7d1f69e diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md new file mode 100644 index 0000000000..886ea98105 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -0,0 +1,100 @@ +# RFC: Same-session goal-round driver + +Status: implemented + +English | [中文](2026-07-19-same-session-goal-round-driver.zh.md) + +## Problem + +The goal domain can retain an objective and the model-facing tools can mutate its lifecycle, but neither should decide when another model turn begins. A continuation driver must bridge active goal state to the ordinary agent loop without adding goal-specific branches to `dsh-agent-loop`, inventing a second conversation, or treating every human turn as an autonomous iteration. + +That bridge has concurrency and durability obligations. Human input, cancellation, a goal edit, persistence failure, session restart, plugin unload, and a downstream prompt policy can all race a pending continuation. A naive `goal/changed -> agent.send()` listener can admit obsolete work, run alongside a human prompt, spend beyond the cap, or restart from replay without new authority. + +## Decision + +`@deepseek-ai/dsh-goal-session` in `packages/goal/goal-session/` is a policy plugin over `ctx.goals`, the public `Agent` interface, and durable session events. It imports no concrete agent-loop implementation. For each exact live `Agent`, it owns process-local scheduling state and may reserve at most one automatic round. + +The hierarchy is Goal → Goal Round → Turn → Step. A goal round is the outer continuation policy iteration; it becomes one goal-sourced session turn, and that turn can contain any number of ordinary model/tool steps. Human turns in the same session are not goal rounds and never increment `roundsStarted`. + +The plugin has no configuration. `maxGoalRounds` is resolved and persisted by `dsh-goal`, and the same-condition blocking threshold is resolved and prompted by `dsh-tool-goal`. Repeating those tunables in the driver would create multiple owners for one policy. + +### Reservation and admission + +When an agent is idle, has no competing queued work, and its current goal is `active` plus `armed`, the driver checkpoints pending goal mutations and rechecks every predicate after the await. If `roundsStarted` already equals `maxGoalRounds`, it records `budget-limited`. Otherwise it reserves the exact identity `{ goalId, revision, round: roundsStarted + 1 }` and the complete rendered prompt before calling `Agent.send()` with `GoalMessageSource`. The prompt JSON-quotes the objective so multiline or tag-like text remains an unambiguous data value inside the familiar frame. + +The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. + +Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation becomes a durable `prompt/blocked` plus zero-step rejected turn, but the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. + +### Human work and revision races + +`agent/queued` distinguishes the driver's complete accepted record from every other prompt. Ordinary work already queued before a reservation prevents scheduling. Ordinary work queued while an automatic prompt is pending makes that reservation stale, so a mixed batch admits the human prompt but rejects the automatic one. Ordinary work arriving after the goal round was admitted remains queued for its own next turn; continuation is reconsidered only when the agent later becomes idle. + +A goal mutation during a round advances its durable revision. Settlement of the older revision cannot overwrite that mutation. The driver discards the old attempt outcome, reads the new projection, and continues only if the new revision is still active and armed. This makes model-recorded completion, pause, block, and edit authoritative over the physical turn's later close reason. + +### Settlement + +The driver classifies one closed goal-owned turn as follows: + +| Turn result | Action | +|---|---| +| durable `completed` | continue while active/armed and under cap | +| broad cancellation / `aborted` | pause and disarm | +| `error` with code `RATE_LIMIT` | mark `usage-limited` | +| other `error` | block | +| `max-tokens` | block | +| non-stale `rejected` | block | +| failed durability checkpoint | disarm without changing durable phase | +| `disposed` or `interrupted` | disarm | +| plugin-added unknown result | block for inspection | + +No abnormal outcome requests an automatic retry. A later human prompt can ask to continue in any language; the model reads the stopped goal and uses the goal tool's resume action, which records a new revision and arms continuation. + +### Durability and cancellation seam + +Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver associates it with the exact attempt and disarms before the next idle decision. + +Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation and pause an active armed goal before the loop destroys the queued-work evidence. + +This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. + +### Process lifecycle + +`GoalService.disarm(agent)` removes only process-local activation. It writes no session event, changes no revision, and emits no goal mutation. The driver calls it while loading over existing agents, on durability uncertainty, and before teardown; a later `resume` is the durable activation edge visible to the model. + +The driver's event listeners and quiescent close are nested in one ordered Cordis effect. Cordis unloads sibling effects concurrently, so separate listener and cleanup registrations could remove the prompt fence while an async disposer was still draining. The composite effect first closes admission, disarms goals, cancels an admitted attempt, and awaits both agent and driver quiescence; only then does it unregister its listeners. + +An inbox acceptance can win the microtask race immediately before plugin unload begins. In that case the turn and even its first request may start and the round remains durably charged; once unload starts, cancellation aborts it, no following round is scheduled, and the goal remains active but disarmed. Pretending that already-observed admission never happened would corrupt replay accounting. + +## Testing + +The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. + +A keyless Loader/stdio process test mounts the real goal domain, goal tools, goal driver, agent loop, persistence, and deterministic adapter through `cordis.yml`. One human turn creates a two-round goal; round one stops normally; round two reads the exact ref and completes it. The external JSONL assertion proves one session, round sources `1, 2`, unchanged round revision, final complete revision, five model steps, and no extra request after the terminal completion tool. + +The core cancellation test proves notification order and containment: observers run only for effective cancellation, can queue replacement work before the inbox clear, cannot veto later observers by throwing, and an idle call emits nothing. + +## Alternatives considered + +- **Add a goal loop inside `dsh-agent-loop`** — rejected because the public queue, prompt, session, cancellation, and status seams are sufficient, and a concrete-loop branch would privilege one policy. +- **Use `agent/turn-continuation` to make every round another step** — rejected because a goal round is an outer policy iteration and must have its own durable user prompt, turn boundary, round count, and failure settlement. +- **Persist a pending reservation** — rejected because a crash cannot prove that queued process memory had reached admission; only the durable `user/message` consumes the round. +- **Retry provider or persistence errors automatically** — rejected because retry policy spends resources and needs explicit authority; stopped phases plus later human resume are simpler and observable. +- **Fork conversation history or spawn a fresh agent for every round** — rejected for this package because the goal is explicitly same-session work. Fresh-agent Ralph execution remains a separate workflow plugin built from subagent and workflow primitives. +- **Reuse every session turn as the round counter** — rejected because human clarification and unrelated work share the session but not the automatic-work budget. + +## Consequences + +- Goal continuation remains a removable plugin and the concrete loop gains only a generic observe-before-cancel notification. +- Replay can reconstruct every admitted round from its exact goal source and prompt; rejected reservations cannot create phantom budget use. +- Human messages and lifecycle mutations win documented races without corrupting the revision or counter. +- Resume and fork remain inert until semantic human intent causes the model to record a resume mutation. +- Conservative failure mapping can require manual continuation after transient failures, but it never hides an automatic retry. + +## Known limitations and deferred work + +- Completion evidence and semantic blocker equivalence remain model judgments. An independent evaluator, completion certificate, or verifier-driven stop policy is deferred to a separate policy plugin. +- This package does not provide Ralph-style fresh-agent attempts, context reset, cross-round evaluator feedback, or workflow-level parallelism; those belong to the separate Ralph workflow tool. +- Cordis unload begins asynchronously. An already accepted inbox item may enter one charged round and start one request before teardown cancellation takes effect; the closing drain prevents every subsequent round. +- `maxGoalRounds` is only an admitted-round limit. Token, currency, wall-clock, and provider-usage budgets require independent policy. +- A custom `Agent` implementation must produce the documented session events, status edges, cancel notification, and quiescence semantics; structural TypeScript compatibility alone cannot verify runtime ordering. diff --git a/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md new file mode 100644 index 0000000000..be063c8a8b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -0,0 +1,100 @@ +# RFC: 同会话目标回合驱动器 + +Status: implemented + +[English](2026-07-19-same-session-goal-round-driver.md) | 中文 + +## 问题 + +目标领域可以保留目标,模型可见工具也可以变更其生命周期,但两者都不应决定下一个模型轮次何时开始。继续执行驱动器必须把活跃目标状态连接到普通 agent(智能体)循环,同时不能向 `dsh-agent-loop` 添加目标专用分支、创建第二段对话,也不能把每个人类轮次都视为自主迭代。 + +这层连接还承担并发与持久性义务。人类输入、取消、目标编辑、持久化失败、会话重启、插件卸载以及下游提示词策略都可能与待处理的继续执行发生竞争。简单的 `goal/changed -> agent.send()` 监听器可能接纳过期工作、与人类提示词同时运行、超出上限消耗资源,或在回放后未经新授权自行重启。 + +## 决策 + +位于 `packages/goal/goal-session/` 的 `@deepseek-ai/dsh-goal-session` 是构建在 `ctx.goals`、公共 `Agent` 接口和持久会话事件之上的策略插件。它不导入具体 agent-loop 实现。对于每个完全相同的实时 `Agent`,它维护进程内调度状态,并且最多保留一个自动回合预留。 + +层次关系为目标(Goal)→ 目标回合(Goal Round)→ 轮次(Turn)→ 步骤(Step)。目标回合是外层继续执行策略的一次迭代;它会成为一个归属于目标的会话轮次,而该轮次可以包含任意数量的普通模型或工具步骤。同一会话中的人类轮次不是目标回合,也绝不会增加 `roundsStarted`。 + +该插件没有配置项。`maxGoalRounds` 由 `dsh-goal` 解析并持久化;“相同阻塞条件”的门槛由 `dsh-tool-goal` 解析并写入提示词。若驱动器重复声明这些可调值,一个策略就会出现多个所有者。 + +### 预留与接纳 + +当 agent 空闲、没有竞争中的排队工作,且当前目标为 `active` 加 `armed` 时,驱动器会先检查点持久化待处理的目标变更,并在等待之后重新校验所有条件。若 `roundsStarted` 已等于 `maxGoalRounds`,它会记录 `budget-limited`;否则,它会先预留精确身份 `{ goalId, revision, round: roundsStarted + 1 }` 和完整渲染提示词,再以 `GoalMessageSource` 调用 `Agent.send()`。提示词用 JSON 引号编码目标描述,使多行或类似标签的文本在熟悉框架中仍是无歧义的数据值。 + +`agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 + +只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。过期预留会生成持久的 `prompt/blocked` 和零步骤 rejected 轮次,但驱动器会把它标记为过期,不消耗回合数。若下游策略拒绝并非由过期导致,目标会进入 blocked,而不会绕过该策略自动重试。 + +### 人类工作与修订竞争 + +`agent/queued` 会区分驱动器自己的完整已接受记录与其他所有提示词。预留之前已经排队的普通工作会阻止调度;自动提示词待处理时进入的普通工作会使该预留过期,因此混合批次只接纳人类提示词而拒绝自动提示词。目标回合已经接纳后到达的普通工作会保留在队列中,成为下一个独立轮次;只有 agent 再次空闲后才重新考虑继续执行。 + +目标在回合内发生变更时会推进持久修订号。旧修订的结算不得覆盖该变更。驱动器会丢弃旧尝试的结果、读取新投影,并且只在新修订仍为 active 与 armed 时继续。因此,模型记录的完成、暂停、阻塞和编辑相对于物理轮次稍后的关闭原因具有最终权威。 + +### 结算 + +驱动器按下表分类一个已经关闭、归属于目标的轮次: + +| 轮次结果 | 动作 | +|---|---| +| 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | +| 广义取消 / `aborted` | 暂停并解除激活 | +| 代码为 `RATE_LIMIT` 的 `error` | 标记为 `usage-limited` | +| 其他 `error` | 阻塞 | +| `max-tokens` | 阻塞 | +| 非过期的 `rejected` | 阻塞 | +| 持久检查点失败 | 解除激活,但不改变持久阶段 | +| `disposed` 或 `interrupted` | 解除激活 | +| 插件新增的未知结果 | 阻塞并等待检查 | + +异常结果都不会请求自动重试。之后的人类提示词可以用任何语言要求继续;模型读取已停止目标并调用目标工具的 resume 动作,记录新修订并重新激活继续执行。 + +### 持久性与取消接缝 + +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;驱动器把它关联到精确尝试,并在下一次空闲决策前解除激活。 + +广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿清除预留并暂停 active 且 armed 的目标,之后循环才销毁排队工作证据。 + +该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 + +### 进程生命周期 + +`GoalService.disarm(agent)` 只移除进程内激活态。它不写会话事件、不改变修订号,也不发出目标变更。驱动器在加载到已有 agent、持久性存在不确定性以及卸载前调用该方法;之后的 `resume` 才是模型可见的持久激活边沿。 + +驱动器的事件监听器和静止关闭嵌套在同一个有序 Cordis effect 中。Cordis 会并发卸载同级 effect;若监听器和清理分别注册,异步 disposer 仍在排空时提示词栅栏就可能已被移除。组合 effect 会先关闭接纳、解除目标激活、取消已接纳尝试,并等待 agent 与驱动器都达到静止;之后才注销监听器。 + +紧邻插件开始卸载前,收件箱接纳可能赢得微任务竞争。在这种情况下,轮次甚至首个请求都可能已经开始,且该回合仍会持久计费;卸载一旦开始,取消就会中止它,不会再调度后续回合,目标保持 active 但 disarmed。若假装已经观测到的接纳从未发生,就会破坏回放计数。 + +## 测试 + +单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 + +无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实目标领域、目标工具、目标驱动器、agent loop、持久化和确定性适配器。一个人类轮次创建两回合目标;第一回合正常停止;第二回合读取精确引用并完成目标。测试从外部检查 JSONL,证明只有一个会话、回合来源依次为 `1, 2`、回合修订号不变、最终完成修订正确、共有五个模型步骤,并且终止性的完成工具后没有额外请求。 + +核心取消测试固定通知顺序与隔离:只有有效取消才会通知;观察者可以在清空收件箱前排入替代工作;抛错不能阻止后续观察者;空闲调用不会发出事件。 + +## 考虑过的替代方案 + +- **在 `dsh-agent-loop` 内添加目标循环**——不予采纳,因为公共队列、提示词、会话、取消和状态接缝已经足够,具体循环分支还会赋予某种策略特权。 +- **使用 `agent/turn-continuation` 把每个回合变成另一个步骤**——不予采纳,因为目标回合是外层策略迭代,必须拥有自己的持久用户提示词、轮次边界、回合计数和失败结算。 +- **持久化待处理预留**——不予采纳,因为崩溃无法证明进程内队列已经达到接纳点;只有持久 `user/message` 才消耗回合。 +- **自动重试提供方或持久化错误**——不予采纳,因为重试会消耗资源,需要显式授权;停止阶段加之后的人类恢复更简单,也可观察。 +- **每回合 fork 对话历史或生成新 agent**——本包不采用,因为此目标明确属于同会话工作。新 agent 的 Ralph 执行仍是基于 subagent 与 workflow 原语的独立工作流插件。 +- **把每个会话轮次当作回合计数**——不予采纳,因为人类澄清和无关工作共享会话,但不共享自动工作预算。 + +## 后果 + +- 目标继续执行仍是可移除插件,具体循环只新增一个通用的“取消前观察”通知。 +- 回放可以从精确目标来源和提示词重建每个已接纳回合;被拒绝的预留不会产生虚假的预算消耗。 +- 人类消息和生命周期变更可以在有文档约束的竞争中胜出,而不破坏修订号或计数器。 +- 恢复和 fork 在语义上的人类意图促使模型记录 resume 变更之前始终保持惰性。 +- 保守的失败映射可能要求在暂时性错误后手动继续,但绝不会隐藏自动重试。 + +## 已知限制与延期工作 + +- 完成证据和阻塞条件的语义等价性仍由模型判断。独立评估器、完成证书或由验证器驱动的停止策略延期到独立策略插件。 +- 本包不提供 Ralph 风格的新 agent 尝试、上下文重置、跨回合评估反馈或工作流级并行;它们属于独立的 Ralph 工作流工具。 +- Cordis 卸载异步开始。已经被收件箱接受的条目可能先进入一个计费回合并启动一个请求,之后卸载取消才生效;关闭排空会阻止所有后续回合。 +- `maxGoalRounds` 只是已接纳回合上限。token、费用、挂钟时间和提供方使用预算需要独立策略。 +- 自定义 `Agent` 实现必须产生文档规定的会话事件、状态边沿、取消通知和静止语义;仅凭 TypeScript 结构兼容无法验证运行时顺序。 diff --git a/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml b/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml new file mode 100644 index 0000000000..9adc5c6909 --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml @@ -0,0 +1,25 @@ +# Test-only composition: one human turn creates a goal, then the driver runs two rounds. +- id: scripted-llm + name: './scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: goal-session-script + model: goal-session-script + persona: 'Execute the deterministic same-session goal-round proof.' + welcome: 'goal-session e2e ready.' + persistenceRoot: './.sessions' + workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts new file mode 100644 index 0000000000..d7507553bf --- /dev/null +++ b/examples/echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts @@ -0,0 +1,104 @@ +/** Deterministic model for the same-session goal-round composition proof. */ + +import type { Context } from 'cordis' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' + +interface GoalState { + readonly id: string + readonly revision: number +} + +/** Text and position of the latest human or goal-round prompt. */ +function latestPrompt(messages: readonly Message[]): { index: number; text: string } { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message?.role !== 'user') continue + const text = message.content + .filter(block => block.type === 'text' && !block.text.startsWith('')) + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text.includes('') || text === 'start') return { index, text } + } + return { index: -1, text: '' } +} + +/** Parse the latest durable goal snapshot retained in request history. */ +function latestGoal(messages: readonly Message[]): GoalState | undefined { + for (const message of [...messages].reverse()) { + for (const block of [...message.content].reverse()) { + if (block.type !== 'text' || !block.text.startsWith('')) continue + const json = block.text.slice(''.length, -''.length) + const value = JSON.parse(json) as { goal?: GoalState } + if (value.goal !== undefined) return value.goal + } + } + return undefined +} + +/** Tool names already recorded after the prompt that owns this physical turn. */ +function callsAfter(messages: readonly Message[], index: number): string[] { + return messages.slice(index + 1).flatMap(message => message.content) + .filter(block => block.type === 'tool-call') + .map(block => block.type === 'tool-call' ? block.name : '') +} + +/** Emit one deterministic tool call, optionally with visible progress text. */ +async function* toolCall(name: string, args: object, text?: string): AsyncIterable { + let index = 0 + if (text !== undefined) { + yield { type: 'block-start', index, blockType: 'text' } + yield { type: 'text-delta', index, text } + yield { type: 'block-end', index, block: { type: 'text', text } } + index += 1 + } + const id = CallId(`call-${name}`) + const raw = JSON.stringify(args) + yield { type: 'block-start', index, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index, id, name, argumentsDelta: raw } + yield { type: 'block-end', index, block: { type: 'tool-call', id, name, arguments: raw } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } +} + +/** Emit one terminal text response. */ +async function* textReply(text: string): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } +} + +class GoalSessionScriptAdapter extends LlmAdapter { + override stream(options: GenerateOptions): AsyncIterable { + const prompt = latestPrompt(options.messages) + const calls = callsAfter(options.messages, prompt.index) + if (prompt.text === 'start' && !calls.includes('create_goal')) { + return toolCall('create_goal', { + objective: 'Complete two deterministic same-session rounds', + max_goal_rounds: 2, + }) + } + if (prompt.text === 'start') return textReply('GOAL CREATED') + if (prompt.text.includes('Round: 1/2')) return textReply('ROUND ONE') + if (prompt.text.includes('Round: 2/2') && !calls.includes('get_goal')) { + return toolCall('get_goal', {}) + } + if (prompt.text.includes('Round: 2/2') && !calls.includes('update_goal')) { + const goal = latestGoal(options.messages) + if (goal === undefined) throw new Error('scripted goal state missing') + return toolCall('update_goal', { + goal_id: goal.id, + revision: goal.revision, + action: 'complete', + }, 'ROUND TWO COMPLETE') + } + return textReply('UNEXPECTED GOAL-SESSION REQUEST') + } +} + +export const name = 'goal-session-scripted-llm' +export const inject = ['llm'] + +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['goal-session-script'], new GoalSessionScriptAdapter()) +} diff --git a/examples/package.json b/examples/package.json index b1c8ac52cd..52cebaee31 100644 --- a/examples/package.json +++ b/examples/package.json @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/knip.json b/knip.json index eb4eff3a9f..c97ea4edea 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", + "echo-agent/tests/fixtures/goal/goal-session/scripted-llm.ts", "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", @@ -72,6 +73,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/goal/goal-session": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/goal/tool-goal": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9262046683..bb3dc9c3a5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -262,6 +262,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'get(agent: Agent): GoalView | undefined', jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', }, + { + signature: 'disarm(agent: Agent): GoalView | undefined', + jsDoc: '/**\n * Remove process-local continuation authority without changing durable goal\n * phase or revision. Lifecycle owners use this before unloading a driver;\n * a later human-authorized {@link resume} records the new activation edge.\n * @param agent - owning live agent.\n * @returns a fresh disarmed view, or `undefined` when no goal is current.\n */', + }, { signature: 'create(agent: Agent, request: CreateGoalRequest): GoalView', jsDoc: '/**\n * Create and arm a goal. A completed goal may be replaced; every other\n * current phase must be cleared or resumed instead.\n * @param agent - owning live agent.\n * @param request - objective and optional round cap.\n * @returns the created live view.\n */', @@ -663,6 +667,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, + { + name: 'agent/cancel-requested', + mode: 'emit', + signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, reason: string): void', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.', + }, { name: 'agent/created', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d604e2b16f..d5fea4cb88 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -54,7 +54,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..c0287d005b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -333,13 +333,18 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { + const resolvedReason = reason ?? 'cancelled' // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / // continuation). The mid-step path reads it from abort.signal.reason // below; the marker path reads it via the LoopHandle's cancelReason(). - this.cancelReason = reason ?? 'cancelled' + this.cancelReason = resolvedReason + // Coordination consumers must update their own state before this call + // clears the inbox or aborts the step. Notification failures are + // contained by the fused dispatcher and cannot veto cancellation. + agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason) } // Drop all pending queued + steering work (un-started prompts never run; the // cancelled turn's steering is not re-enqueued). Cleared directly even when @@ -349,7 +354,7 @@ export class ReactLoopAgent implements Agent { // Interrupt an in-flight step immediately (the running turn observes the // abort and ends `aborted`). The marker covers the windows where no step is // running (pre-step, continuation). - this.currentAbort?.abort(reason ?? 'cancelled') + this.currentAbort?.abort(resolvedReason) } /** diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 14e7cf8976..d4cbaa7375 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -7,7 +7,7 @@ * @module dsh-agent-loop/tests/cancel */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -55,6 +55,33 @@ function userTexts(agent: Agent): string[] { } describe('Agent.cancel()', () => { + it('notifies every observer before clearing work and contains listener failures', async () => { + const adapter = new MockAdapter([textResponse('must remain unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) + const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: string[] = [] + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject !== agent) return + seen.push(`first:${reason}`) + subject.send([{ type: 'text', text: 'queued by cancel observer' }]) + throw new Error('observer failed') + }) + ctx.on('agent/cancel-requested', (subject, reason) => { + if (subject === agent) seen.push(`second:${reason}`) + }) + + send(agent, 'drop me') + agent.cancel() + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel('idle no-op') + + expect(seen).toEqual(['first:cancelled', 'second:cancelled']) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) + }) + it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bf5e563443..384ffeea93 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). `PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -57,7 +57,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. +- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..bb610f3a5b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -120,9 +120,10 @@ export interface Agent { /** * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * the active step. An effective call first emits `agent/cancel-requested` with + * the resolved reason. The supplied reason is preserved across pre-step and + * active cancellation windows, and `whenIdle()` resolves after cancellation + * reaches quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void @@ -173,6 +174,16 @@ declare module 'cordis' { * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void + /** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void // ---- session lifecycle (emit) ---- /** diff --git a/packages/goal/README.md b/packages/goal/README.md index 1ab912b3c7..fd0b94f321 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -5,6 +5,7 @@ The goal family owns durable objective state independently of the model-facing t | Package | Role | ctx key | |---|---|---| | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | +| `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — | | `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md new file mode 100644 index 0000000000..0a73cfb75d --- /dev/null +++ b/packages/goal/goal-session/README.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-goal-session + +Same-session continuation driver for [`ctx.goals`](../goal/README.md). It turns an active, armed goal into sequential [goal rounds](../../../docs/glossary.md#goal-round) through the public `Agent` and session seams; the [same-session driver RFC](../../../docs/rfc/implemented/feature/2026-07-19-same-session-goal-round-driver.md) owns the race and lifecycle rationale. + +## Composition + +```yaml +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' +``` + +The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal definition, while the model-facing blocked threshold belongs to [`dsh-tool-goal`](../tool-goal/README.md); duplicating either value in the driver could produce divergent policy. + +## Round contract + +When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number. + +One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle. + +The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`. + +## Settlement policy + +| Durable turn outcome | Goal action | Automatic retry | +|---|---|---| +| `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes | +| broad cancellation / `aborted` | `paused` | no | +| `error` with `RATE_LIMIT` | `usage-limited` | no | +| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no | +| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | + +A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically. + +## Lifecycle and durability + +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver disarms before another round can start. + +Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. + +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step, allowing this plugin to pause and disarm the exact active goal. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. + +## Model Experience + +### Goal-round prompt + +**What the model sees**: Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. + +**Token effect**: One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. + +## Known Limitations and Deferred Work + +- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred. +- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer. +- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts. +- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; `RATE_LIMIT` only maps an observed provider stop into `usage-limited`. +- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy. diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json new file mode 100644 index 0000000000..c1493d017c --- /dev/null +++ b/packages/goal/goal-session/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-goal-session", + "description": "Race-fenced same-session goal-round driver", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts new file mode 100644 index 0000000000..09ff85cac4 --- /dev/null +++ b/packages/goal/goal-session/src/index.ts @@ -0,0 +1,443 @@ +/** + * Same-session goal-round driver over public agent, session, and goal seams. + * @module @deepseek-ai/dsh-goal-session + */ + +import { isDeepStrictEqual } from 'node:util' +import { FiberState } from 'cordis' +import type { Context } from 'cordis' +import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import { classifyGoalRound } from './outcome.ts' +import type { GoalRoundOutcome } from './outcome.ts' +import { renderGoalRoundPrompt } from './prompt.ts' + +export { classifyGoalRound } from './outcome.ts' +export type { GoalRoundOutcome } from './outcome.ts' +export { renderGoalRoundPrompt } from './prompt.ts' + +export const name = 'goal-session' +export const inject = ['agents', 'goals', 'sessions'] + +const STALE_ROUND_REASON = 'stale goal-round reservation' + +/** Identity reserved before a goal continuation enters the agent inbox. */ +interface RoundIdentity { + readonly goalId: GoalRef['id'] + readonly revision: number + readonly round: number +} + +/** One queued or admitted attempt, retained until its physical turn settles. */ +interface RoundAttempt extends RoundIdentity { + readonly content: ContentBlock[] + phase: 'queued' | 'admitted' + turn: number | undefined + reason: TurnEndReason | undefined + rejectedReason: string | undefined + stale: boolean +} + +/** Serialized process-local scheduling state for one exact Agent lifecycle. */ +interface DriverState { + readonly agent: Agent + attempt: RoundAttempt | undefined + openTurn: number | undefined + competingQueued: boolean + needsCheckpoint: boolean + requested: boolean + run: Promise | undefined + stopping: boolean + readonly flushFailedTurns: Set +} + +/** Whether a source identifies an automatic, positive-numbered goal round. */ +function isGoalRoundSource(source: MessageSource): source is GoalMessageSource { + return source.kind === 'goal' && source.round > 0 +} + +/** Compare a source to one reserved identity. */ +function sameRound(source: GoalMessageSource, round: RoundIdentity): boolean { + return source.goalId === round.goalId + && source.revision === round.revision + && source.round === round.round +} + +/** Compare the complete queued record to the driver's reservation. */ +function sameQueued(content: ContentBlock[], source: MessageSource, attempt: RoundAttempt): boolean { + return isGoalRoundSource(source) && sameRound(source, attempt) && isDeepStrictEqual(content, attempt.content) +} + +/** Exact current ref for a view. */ +function goalRef(goal: GoalView): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Human-readable unexpected values for logs. */ +function renderThrown(value: unknown): string { + return value instanceof Error ? value.message : String(value) +} + +/** Install automatic same-session continuation and its race fences. */ +export function apply(ctx: Context): void { + const states = new Map() + + /** Create state for an exact currently live agent. */ + function stateFor(agent: Agent): DriverState { + const existing = states.get(agent) + if (existing !== undefined) return existing + const state: DriverState = { + agent, + attempt: undefined, + openTurn: undefined, + competingQueued: false, + needsCheckpoint: false, + requested: false, + run: undefined, + stopping: false, + flushFailedTurns: new Set(), + } + states.set(agent, state) + return state + } + + /** Read only when the exact Agent remains live. */ + function currentGoal(state: DriverState): GoalView | undefined { + if (ctx.agents.get(state.agent.id) !== state.agent || state.agent.status === 'disposed') return undefined + return ctx.goals.get(state.agent) + } + + /** Whether this exact lifecycle is quiescent with no competing prompt. */ + function readyToDrive(state: DriverState): boolean { + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping + && ctx.agents.get(state.agent.id) === state.agent + && state.agent.status === 'idle' + && !state.competingQueued + } + + /** Recheck every condition that an awaited checkpoint may have changed. */ + function readyAfterCheckpoint(state: DriverState): boolean { + return readyToDrive(state) && !state.needsCheckpoint + } + + /** Remove automatic authority while preserving the durable phase. */ + function disarm(state: DriverState): void { + try { + const goal = currentGoal(state) + if (goal?.activation === 'armed') ctx.goals.disarm(state.agent) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not disarm agent "${state.agent.id}": ${renderThrown(error)}`) + } + } + + /** Apply one closed-round outcome only to the exact still-current revision. */ + function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void { + const ref = goalRef(goal) + switch (outcome.kind) { + case 'continue': + return + case 'pause': + ctx.goals.pause(state.agent, ref) + return + case 'usage-limited': + ctx.goals.markUsageLimited(state.agent, ref) + return + case 'blocked': + ctx.goals.block(state.agent, ref) + return + case 'disarm': + ctx.goals.disarm(state.agent) + return + /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ + default: + assertNever(outcome, 'goal round outcome') + } + } + + /** Process a settled attempt, then reserve at most one next round. */ + async function drive(state: DriverState): Promise { + const { agent } = state + if (!readyToDrive(state)) return + + if (state.needsCheckpoint) { + state.needsCheckpoint = false + try { + await ctx.sessions.flush(agent.session) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`) + const goal = currentGoal(state) + if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' }) + return + } + // A mutation or ordinary prompt may have arrived while the checkpoint + // was settling. Give it its own checkpoint / turn before reserving. + if (!readyAfterCheckpoint(state)) return + } + + const attempt = state.attempt + if (attempt !== undefined) { + if (attempt.reason === undefined) return + state.attempt = undefined + const turn = attempt.turn + /* v8 ignore next -- a closed attempt acquired its turn at turn/start */ + if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn') + const durable = !state.flushFailedTurns.delete(turn) + const goal = currentGoal(state) + if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision + && goal.phase === 'active' && goal.activation === 'armed') { + const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale + ? { kind: 'blocked', reason: 'rejected', detail: attempt.rejectedReason } as const + : classifyGoalRound(attempt.reason, durable) + if (!attempt.stale) applyOutcome(state, goal, outcome) + } + if (!readyToDrive(state)) return + } + + const goal = currentGoal(state) + if (goal === undefined || goal.phase !== 'active' || goal.activation !== 'armed') return + if (goal.roundsStarted >= goal.maxGoalRounds) { + ctx.goals.markBudgetLimited(agent, goalRef(goal)) + return + } + + const round = goal.roundsStarted + 1 + const content = renderGoalRoundPrompt(goal, round) + const reservation: RoundAttempt = { + goalId: goal.id, + revision: goal.revision, + round, + content, + phase: 'queued', + turn: undefined, + reason: undefined, + rejectedReason: undefined, + stale: false, + } + state.attempt = reservation + try { + agent.send(content, { + source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, + }) + } catch (error: unknown) { + state.attempt = undefined + ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`) + const latest = currentGoal(state) + if (latest !== undefined && latest.id === goal.id && latest.revision === goal.revision + && latest.phase === 'active' && latest.activation === 'armed') { + ctx.goals.block(agent, goalRef(latest)) + } + } + } + + /** Coalesce triggers onto one agent-local serialized driver. */ + function requestDrive(state: DriverState): void { + /* v8 ignore next -- teardown may race a final trigger after synchronously closing admission */ + if (state.stopping) return + state.requested = true + if (state.run !== undefined) return + let run: Promise + try { + run = ctx.agents.withoutInitiator(async () => { + while (state.requested && !state.stopping) { + state.requested = false + try { + await drive(state) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: driver failed for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + } + } + }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not start driver for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + return + } + state.run = run + const retire = (): void => { + state.run = undefined + if (state.requested && !state.stopping) requestDrive(state) + } + void run.then(retire, (error: unknown) => { + ctx.logger.warn(`goal-session: driver task rejected for agent "${state.agent.id}": ${renderThrown(error)}`) + disarm(state) + retire() + }) + } + + // One composite effect owns every listener and the quiescent close. Cordis + // unloads sibling effects concurrently; nesting makes the close run first + // and keeps the admission fence installed until its drain settles. + ctx.effect(function* () { + /** Mark a post-turn persistence failure before idle scheduling can run. */ + ctx.on('agent/error', (agent, turn) => { + const state = stateFor(agent) + const last = agent.session.events.at(-1) + if (last?.type !== 'turn/end' || last.data.turn !== turn) return + if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) + disarm(state) + }) + + ctx.on('agent/created', (agent) => { stateFor(agent) }) + ctx.on('agent/disposed', (agent) => { states.delete(agent) }) + ctx.on('agent/session-start', (agent) => { + const state = stateFor(agent) + state.attempt = undefined + state.openTurn = undefined + state.competingQueued = false + state.needsCheckpoint = false + state.flushFailedTurns.clear() + }) + ctx.on('agent/status', (agent, status) => { + const state = stateFor(agent) + if (status === 'disposed') { + state.stopping = true + return + } + if (status === 'idle') { + state.competingQueued = false + requestDrive(state) + } + }) + ctx.on('agent/queued', (agent, content, info) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(content, info.source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + }) + ctx.on('agent/cancel-requested', (agent, reason) => { + const state = stateFor(agent) + state.attempt = undefined + state.competingQueued = false + const goal = currentGoal(state) + if (goal?.phase === 'active' && goal.activation === 'armed') { + applyOutcome(state, goal, { kind: 'pause', reason }) + } + }) + ctx.on('goal/changed', (agent) => { + const state = stateFor(agent) + state.needsCheckpoint = true + requestDrive(state) + }) + + ctx.on('session/event', (session: Session, event: SessionEvent) => { + const agent = ctx.agents.get(session.id) + if (agent === undefined || agent.session !== session) return + const state = stateFor(agent) + switch (event.type) { + case 'turn/start': + state.openTurn = event.data.turn + if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source) + && sameRound(event.data.trigger.source, state.attempt)) { + state.attempt.turn = event.data.turn + } + return + case 'user/message': + if (state.attempt !== undefined && isGoalRoundSource(event.data.source) + && sameRound(event.data.source, state.attempt)) { + state.attempt.phase = 'admitted' + /* v8 ignore next -- this driver's admitted message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + } + return + case 'prompt/blocked': + if (state.attempt !== undefined && state.attempt.phase === 'queued' + && isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) { + /* v8 ignore next -- this driver's rejected message always follows its observed turn/start */ + if (state.openTurn !== undefined) state.attempt.turn = state.openTurn + state.attempt.rejectedReason = event.data.reason + if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true + } + return + case 'turn/end': + if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason + /* v8 ignore next -- balanced live turns close the open turn just observed by this listener */ + if (state.openTurn === event.data.turn) state.openTurn = undefined + return + default: + return + } + }) + + /** Fail closed unless the queued prompt still owns the exact live revision. */ + function validReservation( + state: DriverState, + content: ContentBlock[], + source: GoalMessageSource, + ): boolean { + const attempt = state.attempt + const goal = currentGoal(state) + return ctx.fiber.state === FiberState.ACTIVE + && !state.stopping && attempt !== undefined && attempt.phase === 'queued' + && !attempt.stale && sameQueued(content, source, attempt) + && goal !== undefined && goal.id === source.goalId && goal.revision === source.revision + && goal.phase === 'active' && goal.activation === 'armed' + && source.round === goal.roundsStarted + 1 + } + + ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise => { + if (!isGoalRoundSource(source)) return next() + const state = stateFor(agent) + let valid = false + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + const decision = await next() + if (decision.kind === 'block') return decision + try { + valid = validReservation(state, content, source) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: post-admission check failed for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + valid = false + } + if (!valid) { + const attempt = state.attempt + if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true + return { kind: 'block', reason: STALE_ROUND_REASON } + } + return decision + }) + + // Loading a lifecycle driver over existing agents never inherits hidden + // automatic authority from an earlier producer instance. + for (const agent of ctx.agents.list()) { + const state = stateFor(agent) + disarm(state) + } + + // Yielded after listener registration, so this close runs first and the + // composite effect removes listeners only after its promise settles. + yield async () => { + const waits: Promise[] = [] + for (const state of states.values()) { + state.stopping = true + disarm(state) + const attempt = state.attempt + if (attempt !== undefined) { + attempt.stale = true + if (attempt.phase === 'admitted' && state.agent.status === 'running') { + state.agent.cancel('goal-session driver disposed') + } + waits.push(state.agent.whenIdle()) + } + if (state.run !== undefined) waits.push(state.run) + } + await Promise.allSettled(waits) + states.clear() + } + }, 'goal-session lifecycle') +} diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts new file mode 100644 index 0000000000..4660f1b7a5 --- /dev/null +++ b/packages/goal/goal-session/src/outcome.ts @@ -0,0 +1,48 @@ +/** Typed settlement policy for one admitted same-session goal round. */ + +import type { TurnEndReason } from '@deepseek-ai/dsh-session' + +/** Driver action derived from one closed goal-owned turn. */ +export type GoalRoundOutcome = + | { readonly kind: 'continue' } + | { readonly kind: 'pause'; readonly reason: string } + | { readonly kind: 'usage-limited'; readonly message: string } + | { + readonly kind: 'blocked' + readonly reason: 'error' | 'max-tokens' | 'rejected' | 'unknown' + readonly detail: string + } + | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } + +/** + * Classify one closed goal round without mutating goal state. + * @param reason - durable reason from the round's `turn/end`. + * @param durable - whether the closing flush reached its durability checkpoint. + * @returns the single driver action; no abnormal outcome requests an automatic retry. + */ +export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome { + if (!durable) return { kind: 'disarm', reason: 'durability-failed' } + const extensibleReason: { readonly kind: string } = reason + switch (reason.kind) { + case 'completed': + return { kind: 'continue' } + case 'aborted': + return { kind: 'pause', reason: reason.reason ?? 'cancelled' } + case 'error': + return reason.code === 'RATE_LIMIT' + ? { kind: 'usage-limited', message: reason.message } + : { kind: 'blocked', reason: 'error', detail: reason.message } + case 'max-tokens': + return { kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' } + case 'rejected': + return { kind: 'blocked', reason: 'rejected', detail: reason.reason } + case 'disposed': + return { kind: 'disarm', reason: 'disposed' } + case 'interrupted': + return { kind: 'disarm', reason: 'interrupted' } + // TurnEndReason is merge-extensible. An unknown producer cannot opt into + // automatic retry merely by adding a tag; stop for inspection instead. + default: + return { kind: 'blocked', reason: 'unknown', detail: `unknown turn outcome: ${extensibleReason.kind}` } + } +} diff --git a/packages/goal/goal-session/src/prompt.ts b/packages/goal/goal-session/src/prompt.ts new file mode 100644 index 0000000000..9a2f69fcd8 --- /dev/null +++ b/packages/goal/goal-session/src/prompt.ts @@ -0,0 +1,26 @@ +/** Model-visible continuation prompt for one same-session goal round. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GoalView } from '@deepseek-ai/dsh-goal' + +/** + * Render the complete goal-round instruction retained in session history. + * @param goal - exact active goal revision being admitted. + * @param round - next positive round number. + * @returns a fresh one-block prompt for `Agent.send()`. + */ +export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] { + return [{ + type: 'text', + text: '\n' + + `Objective: ${JSON.stringify(goal.objective)}\n` + + `Round: ${round}/${goal.maxGoalRounds}\n\n` + + 'Continue working toward the objective in this same session. Treat the current workspace, ' + + 'tool results, and durable session state as authoritative; inspect them instead of assuming ' + + 'earlier narration is still current. Make concrete progress and verify the result. Before ' + + 'claiming completion, gather evidence that the whole objective is achieved, read the current ' + + 'goal, and mark it complete. If work remains, leave the goal active for the next round. Follow ' + + 'the configured goal-tool policy before reporting a blocker.\n' + + '', + }] +} diff --git a/packages/goal/goal-session/tests/goal-session.e2e.ts b/packages/goal/goal-session/tests/goal-session.e2e.ts new file mode 100644 index 0000000000..d1152c5995 --- /dev/null +++ b/packages/goal/goal-session/tests/goal-session.e2e.ts @@ -0,0 +1,138 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { foldGoal } from '@deepseek-ai/dsh-goal' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/goal/goal-session/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +/** Recursively locate persistence JSONL files in one temporary root. */ +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +/** Run the complete deterministic human-turn plus two-round composition. */ +async function runComposition(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'goal-session-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child = proc + let stdout = '' + let stderr = '' + let inputClosed = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!inputClosed && stdout.includes('ROUND TWO COMPLETE') && stdout.includes('\n> ')) { + inputClosed = true + proc.stdin.end() + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error( + `goal-session e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, + )) + }, PROCESS_TIMEOUT_MS) + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`goal-session e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('start\n') + }) +} + +describe('same-session goal rounds through a real Loader, app, and stdio process', () => { + it('persists two exact rounds and stops the completion turn without another request', async () => { + const { stdout, stderr } = await runComposition() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('goal-session e2e ready.') + expect(stdout).toContain('GOAL CREATED') + expect(stdout).toContain('ROUND ONE') + expect(stdout).toContain('ROUND TWO COMPLETE') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + + const calls = events.filter(event => event.type === 'tool/call') + expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) + expect(events.filter(event => event.type === 'step/start')).toHaveLength(5) + expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) + + const rounds = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'goal') + expect(rounds).toHaveLength(2) + const roundNumbers: number[] = [] + const revisions: number[] = [] + const prompts: string[] = [] + for (const event of events) { + if (event.type !== 'user/message' || event.data.source.kind !== 'goal') continue + roundNumbers.push(event.data.source.round) + revisions.push(event.data.source.revision) + prompts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + expect(roundNumbers).toEqual([1, 2]) + expect(revisions).toEqual([1, 1]) + expect(prompts[0]).toContain('Round: 1/2') + expect(prompts[1]).toContain('Round: 2/2') + + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Complete two deterministic same-session rounds', + phase: 'complete', + revision: 2, + maxGoalRounds: 2, + }, + roundsStarted: 2, + }) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts new file mode 100644 index 0000000000..13d6d9bf3f --- /dev/null +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -0,0 +1,648 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' +import type { GoalView } from '@deepseek-ai/dsh-goal' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' +import * as goalSession from '../src/index.ts' + +type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) + +/** Small request-recording adapter with controllable failure and cancellation. */ +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: ScriptEntry[]) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('ScriptedAdapter: script exhausted') + if (entry instanceof Error) throw entry + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + const chunks = typeof entry === 'function' ? entry(options) : entry + for (const chunk of chunks) yield chunk + } +} + +/** One successful text response. */ +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** One successful response cut off at the model output limit. */ +function maxTokensResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ] +} + +/** Complete request history as a single string for ordering assertions. */ +function requestText(request: GenerateOptions): string { + return request.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +interface Harness { + readonly ctx: Context + readonly adapter: ScriptedAdapter + readonly agent: Agent + readonly driver: Awaited> +} + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.allSettled(contexts.splice(0).map(context => context.fiber.dispose())) +}) + +/** Mount a real loop with only its model scripted. */ +async function harness(script: ScriptEntry[]): Promise { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + const driver = await ctx.plugin(goalSession) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId(`goal-session-${Math.random()}`), { + provider: 'mock', + model: 'mock', + }) + return { ctx, adapter, agent, driver } +} + +/** Await a stable goal projection selected by the caller. */ +async function waitForGoal( + ctx: Context, + agent: Agent, + predicate: (goal: GoalView | undefined) => boolean, +): Promise { + await vi.waitFor(() => { + expect(predicate(ctx.goals.get(agent))).toBe(true) + }) + return ctx.goals.get(agent) +} + +/** Await a specific number of dispatched model requests. */ +async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise { + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(count) + }) +} + +describe('goal-round outcome policy', () => { + it.each([ + [{ kind: 'completed' }, true, { kind: 'continue' }], + [{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }], + [{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }], + [{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true, + { kind: 'usage-limited', message: 'slow down' }], + [{ kind: 'error', step: 1, message: 'broken' }, true, + { kind: 'blocked', reason: 'error', detail: 'broken' }], + [{ kind: 'max-tokens' }, true, + { kind: 'blocked', reason: 'max-tokens', detail: 'model output reached max tokens' }], + [{ kind: 'rejected', reason: 'policy' }, true, + { kind: 'blocked', reason: 'rejected', detail: 'policy' }], + [{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }], + [{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }], + [{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }], + [{ kind: 'future-outcome' } as unknown as TurnEndReason, true, + { kind: 'blocked', reason: 'unknown', detail: 'unknown turn outcome: future-outcome' }], + ] as const)('maps %j without abnormal automatic retry', (reason, durable, expected) => { + expect(goalSession.classifyGoalRound(reason, durable)).toEqual(expected) + }) + + it('renders the objective, round budget, authority boundary, and completion protocol', () => { + const goal: GoalView = { + id: GoalId('goal-prompt'), + revision: 4, + objective: 'Ship verified support', + phase: 'active', + maxGoalRounds: 9, + roundsStarted: 2, + createdAt: 1, + updatedAt: 2, + activation: 'armed', + } + const prompt = goalSession.renderGoalRoundPrompt(goal, 3) + expect(prompt).toHaveLength(1) + const block = prompt[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toMatch( + /\nObjective: "Ship verified support"\nRound: 3\/9[\s\S]*current workspace[\s\S]*verify[\s\S]*mark it complete/, + ) + }) + + it('quotes multiline or tag-like objective text as one unambiguous data value', () => { + const goal: GoalView = { + id: GoalId('goal-escaped-prompt'), + revision: 1, + objective: 'first line\n second line', + phase: 'active', + maxGoalRounds: 2, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, + activation: 'armed', + } + const block = goalSession.renderGoalRoundPrompt(goal, 1)[0] + if (block?.type !== 'text') throw new Error('expected a text goal-round prompt') + expect(block.text).toContain('Objective: "first line\\n second line"') + expect(block.text.match(/\n<\/goal_round>/g)).toHaveLength(1) + }) +}) + +describe('same-session goal driving', () => { + it('admits exact numbered rounds until the durable round cap', async () => { + const test = await harness([textResponse('round one'), textResponse('round two')]) + const created = test.ctx.goals.create(test.agent, { objective: 'finish twice', maxGoalRounds: 2 }) + + const final = await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(final).toMatchObject({ id: created.id, roundsStarted: 2, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(2) + const rounds: number[] = [] + for (const event of test.agent.session.events) { + if (event.type === 'user/message' && event.data.source.kind === 'goal') { + rounds.push(event.data.source.round) + } + } + expect(rounds).toEqual([1, 2]) + expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') + expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') + }) + + it('never adopts activation from an already-live driver and waits for explicit resume', async () => { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(GoalService) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new ScriptedAdapter([textResponse('after resume')]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('goal-session-hot-load'), { provider: 'mock', model: 'mock' }) + const created = ctx.goals.create(agent, { objective: 'wait for a human', maxGoalRounds: 1 }) + + await ctx.plugin(goalSession) + await Promise.resolve() + expect(ctx.goals.get(agent)).toMatchObject({ phase: 'active', activation: 'disarmed', revision: 1 }) + expect(adapter.requests).toHaveLength(0) + + ctx.goals.resume(agent, created) + await waitForGoal(ctx, agent, goal => goal?.phase === 'budget-limited') + expect(adapter.requests).toHaveLength(1) + }) + + it.each([ + ['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'], + ['request error', new Error('provider broke'), 'blocked'], + ['max tokens', maxTokensResponse('unfinished'), 'blocked'], + ] as const)('stops after a %s without an automatic retry', async (_label, response, phase) => { + const test = await harness([response]) + test.ctx.goals.create(test.agent, { objective: 'stop safely', maxGoalRounds: 8 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === phase) + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('maps a downstream prompt veto to blocked without admitting the round', async () => { + const test = await harness([]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) + : next()) + test.ctx.goals.create(test.agent, { objective: 'respect policy' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal?.roundsStarted).toBe(0) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'deployment policy')).toBe(true) + }) + + it('does not reserve again when a stopped-goal observer queues ordinary work', async () => { + const test = await harness([textResponse('human follow-up')]) + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) + : next()) + test.ctx.on('goal/changed', (agent, change) => { + if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked') + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + + expect(requestText(test.adapter.requests[0]!)).toContain('inspect the blocker') + }) + + it('pauses and drops a reserved round when cancellation lands before admission', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal') { + cancel() + agent.cancel('operator cancelled pending goal') + } + }) + test.ctx.goals.create(test.agent, { objective: 'do not start yet' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'user/message' + && event.data.source.kind === 'goal')).toBe(false) + }) + + it('pauses an admitted round when cancellation aborts an active step', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'stop in flight' }) + await waitForRequests(test.adapter, 1) + + test.agent.cancel('operator stopped active goal') + await test.agent.whenIdle() + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + + expect(goal).toMatchObject({ roundsStarted: 1, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('lets already-queued human work finish before reserving the next round', async () => { + const test = await harness([textResponse('human answer'), textResponse('goal answer')]) + test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 }) + test.agent.send([{ type: 'text', text: 'human goes first' }]) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('makes a reserved round stale when a listener queues human work behind it', async () => { + const test = await harness([textResponse('human batch'), textResponse('later goal')]) + let inserted = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + inserted = true + agent.send([{ type: 'text', text: 'human joined the pending batch' }]) + }) + test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(2) + expect(requestText(test.adapter.requests[0]!)).toContain('human joined the pending batch') + expect(requestText(test.adapter.requests[0]!)).not.toContain('') + expect(requestText(test.adapter.requests[1]!)).toContain('') + }) + + it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || edited) return + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during queued edit') + test.ctx.goals.edit(agent, current, { objective: 'new objective' }) + }) + test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited') + + expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) + const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked') + expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined) + .toBe('stale goal-round reservation') + const admitted = test.agent.session.events.find(event => event.type === 'user/message' + && event.data.source.kind === 'goal') + expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' + ? admitted.data.source.revision + : undefined).toBe(2) + }) + + it('rechecks revision after downstream prompt hooks before admitting', async () => { + const test = await harness([textResponse('new revision')]) + let edited = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !edited) { + edited = true + const current = test.ctx.goals.get(agent) + if (current === undefined) throw new Error('missing goal during prompt edit') + test.ctx.goals.edit(agent, current, { objective: 'edited downstream' }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'edit during admission', maxGoalRounds: 1 }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'budget-limited') + + expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 }) + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('disarms without dispatch when a durability checkpoint fails', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable'))) + test.ctx.goals.create(test.agent, { objective: 'do not outrun storage' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a checkpoint failure after a clear notification leaves no current goal', async () => { + const test = await harness([]) + test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) + agentEvents(test.ctx, test.agent).emit('goal/changed', { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }) + await new Promise((resolve) => { setImmediate(resolve) }) + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms an admitted round whose closing durability checkpoint fails', async () => { + const test = await harness([textResponse('not durable')]) + test.ctx.on('session/flush', (session) => { + const lastStart = session.events.findLast(event => event.type === 'turn/start') + if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message' + && lastStart.data.trigger.source.kind === 'goal') { + return Promise.reject(new Error('round flush failed')) + } + }) + test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' }) + + const goal = await waitForGoal( + test.ctx, + test.agent, + current => current?.roundsStarted === 1 && current.activation === 'disarmed', + ) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(1) + }) + + it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + throw new Error('queue rejected') + }) + test.ctx.goals.create(test.agent, { objective: 'handle queue failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') + + expect(goal).toMatchObject({ roundsStarted: 0, activation: 'disarmed' }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('preserves a custom agent side effect when send disarms before throwing', async () => { + const test = await harness([]) + vi.spyOn(test.agent, 'send').mockImplementationOnce(() => { + test.ctx.goals.disarm(test.agent) + throw new Error('queue rejected after disarm') + }) + test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains a driver read failure and removes continuation authority', async () => { + const test = await harness([]) + let flushes = 0 + test.ctx.on('session/flush', () => { + flushes += 1 + if (flushes !== 2) return + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('corrupt projection') + }) + }) + test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' }) + await new Promise((resolve) => { setImmediate(resolve) }) + + const goal = test.ctx.goals.get(test.agent) + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains synchronous scheduler startup failure', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => { + throw 'scheduler closed' + }) + test.ctx.goals.create(test.agent, { objective: 'fail startup closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('contains an asynchronously rejected scheduler task', async () => { + const test = await harness([]) + vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce( + () => Promise.reject(new Error('scheduler task rejected')), + ) + test.ctx.goals.create(test.agent, { objective: 'fail task closed' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal?.phase).toBe('active') + expect(test.adapter.requests).toHaveLength(0) + }) + + it('fails a pre-admission read closed even when the first disarm attempt throws', async () => { + const test = await harness([textResponse('retry after containment')]) + let armed = true + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('admission projection failed') + }) + vi.spyOn(test.ctx.goals, 'disarm').mockImplementationOnce(() => { + throw 'disarm failed' + }) + }) + test.ctx.goals.create(test.agent, { objective: 'retry stale admission', maxGoalRounds: 1 }) + + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + + expect(test.adapter.requests).toHaveLength(1) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('fails a post-hook read closed before the prompt can enter history', async () => { + const test = await harness([]) + let armed = true + test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => { + if (source.kind === 'goal' && armed) { + armed = false + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { + throw new Error('post-hook projection failed') + }) + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'block post-hook failure' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks forged goal attribution without touching an absent reservation', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, + }) + await test.agent.whenIdle() + + expect(test.adapter.requests).toHaveLength(0) + expect(test.agent.session.events.some(event => event.type === 'prompt/blocked' + && event.data.reason === 'stale goal-round reservation')).toBe(true) + }) + + it('does not invent goal state when ordinary queued work is cancelled', async () => { + const test = await harness([]) + test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) + test.agent.cancel('ordinary cancellation') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + expect(test.adapter.requests).toHaveLength(0) + }) + + it('blocks admission when downstream cancellation clears the reservation', async () => { + const test = await harness([]) + let cancelled = false + test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + if (source.kind === 'goal' && !cancelled) { + cancelled = true + agent.cancel('cancel from downstream admission policy') + } + return next() + }) + test.ctx.goals.create(test.agent, { objective: 'cancel during admission' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') + await test.agent.whenIdle() + + expect(goal?.roundsStarted).toBe(0) + expect(test.adapter.requests).toHaveLength(0) + }) + + it('disarms and cancels an admitted round before driver teardown completes', async () => { + const test = await harness(['hang']) + test.ctx.goals.create(test.agent, { objective: 'survive plugin unload' }) + await waitForRequests(test.adapter, 1) + + await test.driver.dispose() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + await test.agent.whenIdle() + expect(test.adapter.requests).toHaveLength(1) + }) + + it('cancels an accepted queued round and awaits its driver task during teardown', async () => { + const test = await harness([]) + let unloading: Promise | undefined + test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { + unloading = Promise.resolve(test.driver.dispose()) + } + }) + test.ctx.goals.create(test.agent, { objective: 'unload while queued' }) + await vi.waitFor(() => { expect(unloading).toBeDefined() }) + await unloading + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'disarmed', + roundsStarted: 1, + }) + expect(test.adapter.requests).toHaveLength(1) + }) + + it('resets process-local scheduling state at a session-start edge', async () => { + const test = await harness([textResponse('after explicit resume')]) + const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) + agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + await Promise.resolve() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + + test.ctx.goals.resume(test.agent, created) + await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'budget-limited') + expect(test.adapter.requests).toHaveLength(1) + }) + + it('ignores session events without an exact owning agent and retires disposed agent state', async () => { + const test = await harness([]) + const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan')) + orphan.append('turn/start', { + turn: 1, + trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } }, + }) + orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const handle = await test.ctx.agents.create({ + sessionId: SessionId('goal-session-disposed'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await handle.dispose() + + expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() + }) +}) diff --git a/packages/goal/goal-session/tsconfig.json b/packages/goal/goal-session/tsconfig.json new file mode 100644 index 0000000000..d3cf5d5294 --- /dev/null +++ b/packages/goal/goal-session/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../goal" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 57f366c844..3bc324f785 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -15,7 +15,7 @@ Event-sourced same-session goal state. The service retains one current completio ## Service contract -`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). `disarm()` is the lifecycle-only exception: it removes process-local continuation authority without writing a revision or emitting a mutation. At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. @@ -23,7 +23,7 @@ Every non-clear mutation appends a complete versioned snapshot through `agent.in Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. -Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. +Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. ## Extension points diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 726ceb6c53..7b24fbe824 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -139,6 +139,21 @@ export class GoalService extends Service { return this.view(cache) } + /** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ + disarm(agent: Agent): GoalView | undefined { + this.assertLive(agent) + const cache = this.cache(agent.session) + this.sync(agent.session, cache) + cache.activation = 'disarmed' + return this.view(cache) + } + /** * Create and arm a goal. A completed goal may be replaced; every other * current phase must be cleared or resumed instead. diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c8aa5bc3e3..08445a27ed 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -235,6 +235,20 @@ describe('GoalService creation and replay', () => { expect(() => foldGoal(session.events)).not.toThrow() }) + it('lets a lifecycle owner disarm without writing a durable revision', async () => { + const { ctx, agent, session } = await harness() + const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) + const before = session.events.length + expect(ctx.goals.disarm(agent)).toMatchObject({ + id: goal.id, + revision: goal.revision, + phase: 'active', + activation: 'disarmed', + }) + expect(session.events).toHaveLength(before) + expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) + }) + it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() const impostor = { ...agent, session: new Session(agent.id) } diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 3c112d59a8..a007ab4174 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -28,6 +28,7 @@ function adapt( } const scopedSubjectResolvers = Object.freeze({ + 'agent/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]), 'agent/created': adapt<'agent/created'>(args => args[0]), 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), 'agent/error': adapt<'agent/error'>(args => args[0]), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d3bd1fe7..728fa9ac33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:* version: link:../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:* + version: link:../packages/goal/goal-session '@deepseek-ai/dsh-hooks-claude': specifier: workspace:* version: link:../packages/hooks/hooks-claude @@ -1010,6 +1013,39 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/goal-session: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/tool-goal: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index db84df1542..30e266da46 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -27,6 +27,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 27b0e1d184..d64438c4ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ { "path": "./packages/core/agent" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, + { "path": "./packages/goal/goal-session" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 316af95351..e959832c12 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,10 +2,34 @@ # Harness events -Every event the harness packages declare on the cordis event bus (43 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (44 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* +### agent/cancel-requested + +**Mode:** `emit` + +```ts website-api +/** + * Effective broad cancellation was requested, before queued/steering work + * is cleared or the active step is aborted. This observe-only notification + * cannot veto cancellation; listener failures are contained. + * @param agent - the agent whose current work is being cancelled. + * @param reason - resolved cancellation reason, including the default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +``` + +Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. + +- `agent` — the agent whose current work is being cancelled. +- `reason` — resolved cancellation reason, including the default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L186) + ### agent/created **Mode:** `emit` @@ -28,7 +52,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L148) ### agent/disposed @@ -50,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L157) ### agent/error @@ -77,7 +101,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L322) ### agent/post-step @@ -105,7 +129,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L275) ### agent/pre-step @@ -133,7 +157,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L215) ### agent/prompt-submit @@ -158,7 +182,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L225) ### agent/queued @@ -183,7 +207,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L176) ### agent/request @@ -211,7 +235,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L237) ### agent/request-error @@ -243,7 +267,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L289) ### agent/session-prefix @@ -273,7 +297,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) ### agent/session-start @@ -298,7 +322,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L199) ### agent/status @@ -321,7 +345,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L166) ### agent/step-result @@ -348,7 +372,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L263) ### agent/turn-continuation @@ -373,7 +397,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L299) ### agent/turn-stop @@ -397,7 +421,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L309) ## agent-loop/* diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 146cb04193..a5a4a4471e 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -47,6 +47,27 @@ Read the current goal for one exact live agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) +### ctx.goals.disarm(agent) + +```ts website-api +/** + * Remove process-local continuation authority without changing durable goal + * phase or revision. Lifecycle owners use this before unloading a driver; + * a later human-authorized {@link resume} records the new activation edge. + * @param agent - owning live agent. + * @returns a fresh disarmed view, or `undefined` when no goal is current. + */ +disarm(agent: Agent): GoalView | undefined +``` + +Remove process-local continuation authority without changing durable goal phase or revision. Lifecycle owners use this before unloading a driver; a later human-authorized resume records the new activation edge. + +- `agent` — owning live agent. + +**Returns** a fresh disarmed view, or `undefined` when no goal is current. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) + ### ctx.goals.create(agent, request) ```ts website-api @@ -67,7 +88,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L164) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +111,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L189) ### ctx.goals.pause(agent, ref) @@ -111,7 +132,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L210) ### ctx.goals.resume(agent, ref) @@ -133,7 +154,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221) ### ctx.goals.complete(agent, ref) @@ -154,7 +175,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L246) ### ctx.goals.block(agent, ref) @@ -175,7 +196,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L263) ### ctx.goals.markUsageLimited(agent, ref) @@ -196,7 +217,7 @@ Mark an active goal stopped by an external usage limit. **Returns** the usage-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L273) ### ctx.goals.markBudgetLimited(agent, ref) @@ -217,7 +238,7 @@ Mark an active goal stopped at its configured round cap. **Returns** the budget-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L283) ### ctx.goals.clear(agent, ref) @@ -238,4 +259,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L310) From 5e0e4b2401a181abba82c48deedfb21648729acd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:32:23 +0800 Subject: [PATCH 036/132] fix(web): reject credentialed search redirects --- packages/web/web-search-deepseek/README.md | 2 +- .../web/web-search-deepseek/src/provider.ts | 3 +- .../tests/deepseek.spec.ts | 1 + .../tests/redirect.spec.ts | 123 ++++++++++++++++++ packages/web/web-search-exa/README.md | 2 +- packages/web/web-search-exa/src/provider.ts | 3 +- packages/web/web-search-exa/tests/exa.spec.ts | 1 + packages/web/web-search-perplexity/README.md | 2 +- .../web/web-search-perplexity/src/provider.ts | 3 +- .../tests/perplexity.spec.ts | 1 + 10 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 packages/web/web-search-deepseek/tests/redirect.spec.ts diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index e3045d7f7c..e71a769725 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -37,7 +37,7 @@ DeepSeek returns no provider-generated answer surface this provider trusts as `c Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`. -Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. +Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. ## Model Experience diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index b272565441..cd259a999d 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -127,7 +127,7 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu return { sources, truncated: false } } -/** The DeepSeek-backed search provider. */ +/** The DeepSeek-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID @@ -145,6 +145,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/messages`, { method: 'POST', + redirect: 'error', headers: { // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy // may expect `Authorization: Bearer` — send both so either resolves. diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index afca4ecce2..5a9972cfd6 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -162,6 +162,7 @@ describe('DeepSeekSearchProvider request mapping', () => { await new DeepSeekSearchProvider(options).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) const headers = init.headers as Record expect(headers['x-api-key']).toBe('ds-key') expect(headers['authorization']).toBe('Bearer ds-key') diff --git a/packages/web/web-search-deepseek/tests/redirect.spec.ts b/packages/web/web-search-deepseek/tests/redirect.spec.ts new file mode 100644 index 0000000000..100d60f390 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/redirect.spec.ts @@ -0,0 +1,123 @@ +/** + * Real HTTP coverage proves whether native `fetch` contacts a cross-origin `Location`; mocked + * request-init assertions alone cannot observe that boundary. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek' + +const TEST_API_KEY = 'redirect-test-key' +const TEST_QUERY = 'private redirect query' +const targetRequests: ReceivedRequest[] = [] + +interface ReceivedRequest { + readonly body: string + readonly headers: IncomingMessage['headers'] + readonly method?: string +} + +let redirectOrigin: string +let targetOrigin: string + +const targetServer = createServer((request, response) => { + void captureRequest(request).then((received) => { + targetRequests.push(received) + response.writeHead(204).end() + }, (error: unknown) => response.destroy(asError(error))) +}) + +const redirectServer = createServer((request, response) => { + request.resume() + const status = Number(new URL(request.url ?? '/', 'http://fixture.test').pathname.split('/')[1]) + response.writeHead(status, { location: `${targetOrigin}/collect` }).end() +}) + +beforeAll(async () => { + targetOrigin = await listen(targetServer) + redirectOrigin = await listen(redirectServer) +}) + +afterAll(async () => { + await Promise.all([close(redirectServer), close(targetServer)]) +}) + +describe('DeepSeekSearchProvider redirect policy', () => { + it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => { + targetRequests.length = 0 + const provider = new DeepSeekSearchProvider({ + apiKey: TEST_API_KEY, + baseURL: `${redirectOrigin}/${status}`, + model: 'deepseek-chat', + apiVersion: '2023-06-01', + maxTokens: 32, + maxUses: 1, + }) + + await expect(provider.search({ query: TEST_QUERY })) + .rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) + expect(targetRequests).toHaveLength(0) + }) + + it('shows default 307 following forwards the custom credential and POST body', async () => { + targetRequests.length = 0 + const body = JSON.stringify({ query: TEST_QUERY }) + await fetch(`${redirectOrigin}/307`, { + method: 'POST', + headers: { + 'x-api-key': TEST_API_KEY, + 'authorization': `Bearer ${TEST_API_KEY}`, + 'content-type': 'application/json', + }, + body, + }) + + expect(targetRequests).toHaveLength(1) + expect(targetRequests[0]).toMatchObject({ method: 'POST', body }) + expect(targetRequests[0]?.headers['x-api-key']).toBe(TEST_API_KEY) + }) +}) + +/** Read a complete request received by the redirect target. */ +function captureRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = [] + request.on('data', (chunk: unknown) => { + if (typeof chunk === 'string' || chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk)) + else reject(new TypeError('unexpected HTTP request chunk')) + }) + request.once('error', reject) + request.once('end', () => { + resolve({ + ...request.method !== undefined ? { method: request.method } : {}, + headers: request.headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + }) + }) +} + +/** Listen on an ephemeral loopback port and return the server origin. */ +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() as AddressInfo + return `http://127.0.0.1:${address.port}` +} + +/** Close a listening fixture server after every request has settled. */ +async function close(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) +} + +/** Normalize an unknown fixture failure for `ServerResponse.destroy`. */ +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 47a420e3ca..e4636b7b89 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. ## Model Experience diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index ffc82683b1..3c62dabfa5 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -80,7 +80,7 @@ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { return { sources, truncated: false } } -/** The Exa-backed search provider. */ +/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class ExaSearchProvider implements WebSearchProvider { readonly id = EXA_PROVIDER_ID @@ -100,6 +100,7 @@ export class ExaSearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/search`, { method: 'POST', + redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 40c7afda6f..6e29b10aa8 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -96,6 +96,7 @@ describe('ExaSearchProvider request mapping', () => { expect(fetchMock).toHaveBeenCalledOnce() const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.exa.test/search') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) expect((init.headers as Record)['authorization']).toBe('Bearer exa-key') expect(JSON.parse(init.body as string)).toEqual({ query: 'hello', diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index 909930424b..32d6fc53cc 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). +`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). ## Model Experience diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 8ec5231627..fc6cb9df19 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -82,7 +82,7 @@ export function mapPerplexityResponse(response: PerplexityResponse): WebSearchRe } } -/** The Perplexity-backed search provider. */ +/** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class PerplexitySearchProvider implements WebSearchProvider { readonly id = PERPLEXITY_PROVIDER_ID @@ -103,6 +103,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/chat/completions`, { method: 'POST', + redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index 9662226bc6..b622342384 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -90,6 +90,7 @@ describe('PerplexitySearchProvider request mapping', () => { await new PerplexitySearchProvider(options).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.perplexity.test/chat/completions') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) expect((init.headers as Record)['authorization']).toBe('Bearer pplx-key') expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] }) }) From 80b07984c4c43769324760a3c58190b6f59efc6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:56:02 +0800 Subject: [PATCH 037/132] docs(web): codify credentialed redirect policy --- packages/web/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/web/AGENTS.md diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md new file mode 100644 index 0000000000..1b9721d9ca --- /dev/null +++ b/packages/web/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — Web Packages + +These rules supplement the package conventions in [packages/AGENTS.md](../AGENTS.md). + +- **Reject redirects on credential-bearing provider requests.** Configure the HTTP client to fail before following any redirect response. Regression coverage must prove that the redirect target is not contacted and that every credentialed provider opts into the policy. The configured endpoint necessarily receives the initial request; this prevents automatic forwarding of credentials or request data to another origin, not compromise of the configured endpoint. From 2850c22b7a075ce77d8bb07d80cdeb5cd1917118 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:11:59 +0800 Subject: [PATCH 038/132] feat(ui): add plugin command registry --- docs/architecture.md | 23 +- docs/capability-seams.md | 7 + docs/config-catalog.md | 13 +- docs/cordis-catalog/events.md | 17 + docs/cordis-catalog/services.md | 44 +++ docs/core-data-structures/commands.md | 95 ++++++ docs/core-data-structures/core.md | 1 + docs/event-producer-consumer.md | 1 + docs/glossary.md | 6 + docs/module-graph.md | 16 +- docs/rfc/INDEX.md | 1 + ...7-19-plugin-command-registration.i18n.yaml | 6 + .../2026-07-19-plugin-command-registration.md | 80 +++++ ...26-07-19-plugin-command-registration.zh.md | 80 +++++ .../advanced-toolchain/stdout.golden.jsonl | 1 + .../snapshots/bash-spill/stdout.golden.jsonl | 1 + .../both-mode-turn/stdout.golden.jsonl | 1 + .../cancel-tool-calls/stdout.golden.jsonl | 1 + .../snapshots/cancel/stdout.golden.jsonl | 1 + .../code-mode-turn/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../config-options/stdout.golden.jsonl | 1 + .../cordis-inspect-jsdoc/stdout.golden.jsonl | 1 + .../error-finish/stdout.golden.jsonl | 1 + .../escalation-approved/stdout.golden.jsonl | 1 + .../escalation-rejected/stdout.golden.jsonl | 1 + .../snapshots/fs-edit/stdout.golden.jsonl | 1 + .../fs-policy-reject/stdout.golden.jsonl | 1 + .../fs-read-window/stdout.golden.jsonl | 1 + .../snapshots/fs-read/stdout.golden.jsonl | 1 + .../fs-terminal-card/stdout.golden.jsonl | 1 + .../fs-write-overwrite/stdout.golden.jsonl | 1 + .../snapshots/fs-write/stdout.golden.jsonl | 1 + .../snapshots/handshake/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../hook-cc-pretool-ask/stdout.golden.jsonl | 1 + .../hook-cc-pretool-deny/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../hook-cc-stop-continue/stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../stdout.golden.jsonl | 1 + .../model-switching/stdout.golden.jsonl | 1 + .../snapshots/multi-turn/stdout.golden.jsonl | 1 + .../parallel-tool-calls/stdout.golden.jsonl | 1 + .../permission-switching/stdout.golden.jsonl | 1 + .../repeat-tool-guard/stdout.golden.jsonl | 1 + .../snapshots/skill-load/stdout.golden.jsonl | 1 + .../subagent-fork/stdout.golden.jsonl | 1 + .../subagent-mixed/stdout.golden.jsonl | 1 + .../subagent-multi/stdout.golden.jsonl | 1 + .../subagent-spawn/stdout.golden.jsonl | 1 + .../snapshots/text-turn/stdout.golden.jsonl | 1 + .../snapshots/todo-plan/stdout.golden.jsonl | 1 + .../tool-call-turn/stdout.golden.jsonl | 1 + .../workflow-run/stdout.golden.jsonl | 1 + .../workspace-context/stdout.golden.jsonl | 1 + .../workspace-edit/stdout.golden.jsonl | 1 + examples/tui-agent/tests/tui.snapshot.ts | 2 + knip.json | 4 + .../cordis/tool-cordis/src/api-catalog.ts | 53 +++ packages/examples/README.md | 4 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/package.json | 4 +- packages/examples/acp-demo/src/index.ts | 6 +- packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/stdio-demo/README.md | 3 +- packages/examples/stdio-demo/package.json | 4 +- packages/examples/stdio-demo/src/index.ts | 10 +- packages/examples/stdio-demo/tsconfig.json | 3 + packages/sdk/helper/README.md | 2 +- .../sdk/helper/src/features/builtin/app.ts | 4 + packages/sdk/helper/tests/project.spec.ts | 1 + packages/ui/README.md | 3 +- packages/ui/acp/README.md | 23 +- packages/ui/acp/acp-feature-support.md | 17 +- packages/ui/acp/package.json | 2 + packages/ui/acp/src/index.ts | 101 +++++- packages/ui/acp/tests/commands.spec.ts | 258 ++++++++++++++ packages/ui/acp/tests/harness.ts | 2 + packages/ui/acp/tsconfig.json | 3 + packages/ui/commands/README.md | 31 ++ packages/ui/commands/package.json | 35 ++ packages/ui/commands/src/index.ts | 319 ++++++++++++++++++ packages/ui/commands/tests/commands.spec.ts | 262 ++++++++++++++ packages/ui/commands/tsconfig.json | 24 ++ packages/ui/tui/README.md | 8 +- packages/ui/tui/package.json | 2 + packages/ui/tui/src/index.ts | 142 +++++--- packages/ui/tui/tests/harness.ts | 2 + packages/ui/tui/tests/plugin-shape.spec.ts | 2 +- .../snapshots/disposed-terminal.golden.txt | 43 ++- .../snapshots/errors-and-help.golden.txt | 43 ++- packages/ui/tui/tests/tui.spec.ts | 87 +++++ packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 30 ++ python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 4 + scripts/gen-doc-graphs.ts | 8 + scripts/type-equiv.manifest.json | 8 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/.vitepress/config/api-sidebar.json | 4 + website/zh-CN/api/harness/commands.md | 97 ++++++ website/zh-CN/api/harness/events.md | 21 +- 110 files changed, 2000 insertions(+), 130 deletions(-) create mode 100644 docs/core-data-structures/commands.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md create mode 100644 packages/ui/acp/tests/commands.spec.ts create mode 100644 packages/ui/commands/README.md create mode 100644 packages/ui/commands/package.json create mode 100644 packages/ui/commands/src/index.ts create mode 100644 packages/ui/commands/tests/commands.spec.ts create mode 100644 packages/ui/commands/tsconfig.json create mode 100644 website/zh-CN/api/harness/commands.md diff --git a/docs/architecture.md b/docs/architecture.md index d0cc86895a..b83c7494bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,35 +149,36 @@ Streaming uses raw chunks (`block-start` through `finish`) and `BlockAssembler`. ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: the interface owns its `ctx` key and events, an implementation registers a backend, and a consumer exposes model behavior through tools or prompts. Bash is the reference; the [capability graph](capability-seams.md) shows every family. +A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Some seams bend the template deliberately: LLM combines interface and consumer because adapters implement it; filesystem wraps provider primitives with policy; web keeps search/fetch provider registries behind one service; skills and subagents use named providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [RFC](rfc/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` composes `agent/session-prefix` baselines and `ctx.fs` changes; its [RFC](rfc/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). Apps add front doors; TUI/ACP mount commands ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`, including the Python SDK default ([Python SDK](../python/README.md)). Deployments stay thin with swappable backends/tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes -New behavior should attach to a documented extension point; changing the shipped loop requires updating this map. +New behavior attaches to a documented extension point; a loop change updates this map. | Goal | Mechanism | |---|---| | Add a model provider | register an adapter on `ctx.llm` | -| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly | -| Add command execution | implement and register a `ctx.bash` backend | -| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | +| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | +| Add shell execution | implement and register a `ctx.bash` backend | +| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | +| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop | -| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | +| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | | Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | +| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 64ddcc40c5..50cb528951 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -46,6 +46,9 @@ flowchart LR pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] pkg_stdio_demo["stdio-demo"] + pkg_commands["commands"] + svc_commands["ctx.commands
Human command registry"] + pkg_tui["tui"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] @@ -107,6 +110,7 @@ flowchart LR pkg_bash_sandbox --> svc_bash pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker --> svc_codeRuntime + pkg_commands --> svc_commands pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_fs --> svc_fs @@ -159,6 +163,8 @@ flowchart LR svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash svc_codeRuntime --> pkg_tools + svc_commands --> pkg_acp + svc_commands --> pkg_tui svc_compact --> pkg_compact_basic svc_fs --> pkg_tool_fs svc_llm --> pkg_agent_loop @@ -217,6 +223,7 @@ flowchart LR | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP resolve each agent and surface without sending the invocation to the model. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7b3a700742..2b540f61a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `commands` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:217`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -71,7 +71,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:34`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -896,7 +896,7 @@ export type TerminalMode = 'auto' | 'readline' | 'tui' Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:75`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:76`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1256,7 +1256,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `userInteraction` · `tools` +Requires: `agents` · `commands` · `userInteraction` · `tools` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1288,7 +1288,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1485,6 +1485,7 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 2ab18f89b8..64d83565b6 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -410,6 +410,23 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) +## `commands/*` + +### `commands/change` — emit + +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. + +```ts cordis-catalog +/** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ +'commands/change'(): void +``` + +Source: [`packages/ui/commands/src/index.ts:93`](../../packages/ui/commands/src/index.ts) + ## `fs/*` ### `fs/edit-intent` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index de04e18628..c098100dbe 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -340,6 +340,50 @@ Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResu Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts) +## `ctx.commands` — `CommandService` + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +```ts cordis-catalog +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void + +/** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ +list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] + +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ +find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined + +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ +async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) + +Source: [`packages/ui/commands/src/index.ts:216`](../../packages/ui/commands/src/index.ts) + ## `ctx.compact` — `CompactService` (abstract seam) Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md new file mode 100644 index 0000000000..6f2103a4b4 --- /dev/null +++ b/docs/core-data-structures/commands.md @@ -0,0 +1,95 @@ +# Human Commands + +The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and ACP adapters use it to discover and directly execute plugin-owned commands for an exact agent without creating a model message. The [command RFC](../rfc/implemented/feature/2026-07-19-plugin-command-registration.md) owns dispatch and lifecycle rationale; the [package README](../../packages/ui/commands/README.md) owns composition and limitations. + +Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) + +## Surface and input metadata + +A definition selects one or more adapter identities. The shipped identities are `tui` and `acp`; the string intersection keeps the registry extensible without widening editor autocomplete to plain `string`. ACP currently exposes one unstructured-input hint. + +```ts type-equiv +/** A UI adapter capable of listing and executing human commands. */ +type CommandSurface = 'tui' | 'acp' | (string & {}) +``` + +```ts type-equiv +/** Immutable command input metadata compatible with ACP unstructured input. */ +interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} +``` + +## Definition + +`CommandDefinition` is the plugin-authored registration. Omitted surfaces resolve to both shipped adapters; the registry validates and freezes a detached effective definition. + +```ts type-equiv +/** Plugin-owned command registration. */ +interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces exposing this command; omission means both shipped surfaces. */ + readonly surfaces?: readonly CommandSurface[] + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} +``` + +## Invocation and result + +The adapter owns cancellation and passes the exact target agent. `rawInput` begins immediately after the parsed name and retains the adapter-delivered separator and suffix. Results are direct UI outcomes, not tool results or session events. + +```ts type-equiv +/** Invocation passed to one registered command handler. */ +interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** UI adapter that dispatched the command. */ + readonly surface: CommandSurface + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Expected command outcome rendered directly by the dispatching UI. */ +type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } +``` + +## Discovery and parsing views + +Adapters receive handler-free immutable descriptors after scope resolution and surface filtering. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. + +```ts type-equiv +/** Handler-free immutable command view returned to UI adapters. */ +interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces on which this definition is visible. */ + readonly surfaces: readonly CommandSurface[] +} +``` + +```ts type-equiv +/** Syntactically valid slash command before registry resolution. */ +interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} +``` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 76d94cec79..b89e9fa891 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | +| [commands.md](commands.md) | the human-command seam: definitions, surface discovery, direct invocation, results, and parsing views | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 96bac92e01..b0de06615e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,6 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:93`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`emit`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/glossary.md b/docs/glossary.md index 74dc5e14d5..7759f45542 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -22,6 +22,12 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. - **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. +## human command + +- **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. +- **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. +- **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent. + ## loop hierarchy - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. diff --git a/docs/module-graph.md b/docs/module-graph.md index 71f69f0594..ffdcc066af 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -109,6 +109,7 @@ flowchart TD subgraph group_ui["packages/ui"] pkg_acp["acp"] pkg_app_boot["app-boot"] + pkg_commands["commands"] pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] pkg_stdio["stdio"] @@ -225,6 +226,8 @@ flowchart TD pkg_invariants --> pkg_llm pkg_invariants --> pkg_scope pkg_invariants --> pkg_session + pkg_commands --> pkg_agent + pkg_commands --> pkg_scope pkg_user_approval --> pkg_agent pkg_user_approval --> pkg_brand pkg_user_approval --> pkg_llm @@ -335,6 +338,7 @@ flowchart TD pkg_agent_loop_testkit --> pkg_tools pkg_acp --> pkg_agent pkg_acp --> pkg_bash + pkg_acp --> pkg_commands pkg_acp --> pkg_llm pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox @@ -402,6 +406,7 @@ flowchart TD pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_commands pkg_tui --> pkg_llm pkg_tui --> pkg_session pkg_tui --> pkg_tools @@ -437,6 +442,7 @@ flowchart TD pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -453,6 +459,7 @@ flowchart TD pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_commands pkg_stdio_demo --> pkg_llm pkg_stdio_demo --> pkg_session pkg_stdio_demo --> pkg_session_persistence_jsonl @@ -514,6 +521,7 @@ flowchart TD | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | @@ -537,7 +545,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | @@ -550,11 +558,11 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 72e60ffca7..ebad36fc1b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -91,6 +91,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | +| [Plugin-owned human command registration](implemented/feature/2026-07-19-plugin-command-registration.md) | 2026-07-19 | | [Same-session goal-round driver](implemented/feature/2026-07-19-same-session-goal-round-driver.md) | 2026-07-19 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml new file mode 100644 index 0000000000..5db909187f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-plugin-command-registration.md: 6065644f7c7948799191e7aea276c4f2db16c9fe +2026-07-19-plugin-command-registration.zh.md: 9633b20aef70e1109952f39ebb76e78fa46169ff diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md new file mode 100644 index 0000000000..6065644f7c --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md @@ -0,0 +1,80 @@ +# RFC: Plugin-owned human command registration + +Status: implemented + +English | [中文](2026-07-19-plugin-command-registration.zh.md) + +## Problem + +The TUI owns seven slash commands, while ACP defines a standard command catalog and invocation shape. Keeping command names, help text, autocomplete, dispatch, and cancellation inside each adapter makes every new command an adapter edit, prevents optional plugins from contributing commands, and lets the two front doors drift. Treating slash input as an ordinary model prompt is also unsafe: a user-visible direct action can unexpectedly consume tokens or let the model reinterpret an unknown command. + +A shared mechanism must remain a UI concern rather than a model tool or agent-loop branch. It also needs exact per-agent visibility, HMR-safe removal, per-session ACP discovery, direct result rendering, and request-scoped cancellation without adding command text or output to model history. + +## Decision + +`@deepseek-ai/dsh-commands` in `packages/ui/commands/` is the product command registry. The terminal and ACP app bundles mount it beside their consuming front door, and the SDK project helper emits the same service when scaffolding ACP directly; the executor-less, UI-less agent spine remains independent. TUI and ACP inject the service, while command producers depend only on the registry and any domain they operate. + +### Registry contract + +A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, an optional non-empty surface list, and an abortable handler. Omitted surfaces resolve to `tui` plus `acp`. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. + +`list(agent, surface)` returns immutable name-sorted descriptors after surface filtering and scoped shadowing. `find(agent, surface, name)` resolves the effective definition. `execute(agent, surface, line, signal)` parses and runs a visible definition, returning a detached `success` or `error` result; invalid syntax, unknown names, and hidden definitions return `undefined` so the adapter owns its direct error text. + +`parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision. + +### Scope and lifecycle + +An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph. + +Registration and removal emit the unfiltered `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. + +### Direct dispatch and cancellation + +Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, surface, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. + +Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state. + +### TUI mapping + +The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live `tui` catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. + +Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. + +### ACP mapping + +The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. + +ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`. + +One model prompt or direct command may be in flight per ACP session, independently across sessions. `session/cancel` aborts the direct command when one owns the request; it calls `Agent.cancel()` only for an agent prompt, so cancelling a command cannot destroy unrelated queued or injected agent work. Connection teardown aborts commands and then disposes the owned agents. + +## Testing + +The registry suite covers syntax boundaries, immutable normalization, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, change-notification rollback, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. + +TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. + +## Alternatives considered + +- **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing every front door. +- **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. +- **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly. +- **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. +- **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. +- **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events. +- **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation. + +## Consequences + +- Command producers are ordinary removable plugins, and TUI/ACP share one validated catalog and dispatch contract. +- Agent-specific definitions retain existing flat scope and shadow semantics without a core-to-UI dependency. +- Unknown slash input and command output are deterministic UI behavior with zero direct model tokens. +- ACP clients receive current per-session snapshots after creation, load, registration, and HMR removal. +- Direct command cancellation is isolated from model-turn cancellation. + +## Known limitations and deferred work + +- Input metadata is ACP's current unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later protocol extension. +- Generic command output is live-only and is not reconstructed after TUI restart or ACP reconnect. +- Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. +- The shipped line-oriented `dsh-stdio` and JSON-RPC SDK front doors do not expose the command plane; only TUI and ACP consume it. diff --git a/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md new file mode 100644 index 0000000000..9633b20aef --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -0,0 +1,80 @@ +# RFC:插件拥有的人类命令注册 + +Status: implemented + +[English](2026-07-19-plugin-command-registration.md) | 中文 + +## 问题 + +TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形态。如果命令名、帮助文本、自动补全、分派和取消都留在各适配器内部,每个新命令都需要修改适配器,可选插件无法贡献命令,两个前端也会逐渐偏离。把斜杠输入当作普通模型提示同样不安全:用户可见的直接操作可能意外消耗 token,或让模型重新解释未知命令。 + +共享机制必须仍是 UI 关注点,而不是模型工具或智能体循环分支。它还需要精确的逐智能体可见性、可安全 HMR 移除、逐会话 ACP 发现、直接结果渲染和请求作用域取消,同时不得把命令文本或输出加入模型历史。 + +## 决策 + +位于 `packages/ui/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。终端与 ACP 应用 bundle(组合包)把它挂载在消费该服务的前端旁,SDK 项目 helper(辅助器)在直接搭建 ACP 时也会生成同一服务;无执行器、无 UI 的智能体 spine(主干)保持独立。TUI 与 ACP 注入该服务,命令生产者只依赖注册表及其操作的领域。 + +### 注册表契约 + +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示、可选的非空界面列表,以及可取消处理器。省略界面时解析为 `tui` 与 `acp`。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。 + +`list(agent, surface)` 在界面过滤与作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, surface, name)` 解析有效定义。`execute(agent, surface, line, signal)` 解析并运行可见定义,返回分离后的 `success` 或 `error` 结果;无效语法、未知名称和对该界面隐藏的定义返回 `undefined`,由适配器拥有直接错误文本。 + +`parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 + +### 作用域与生命周期 + +无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 + +注册和移除会发出未过滤的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 + +### 直接分派与取消 + +命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、界面、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 + +预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 + +### TUI 映射 + +TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时 `tui` 目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 + +每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 + +### ACP 映射 + +桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 + +ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。 + +每个 ACP 会话同时只能有一个模型提示或直接命令进行中,各会话彼此独立。当直接命令拥有请求时,`session/cancel` 会中止它;只有智能体提示才调用 `Agent.cancel()`,因此取消命令不会销毁无关的排队或注入智能体工作。连接清理会先中止命令,再释放所拥有的智能体。 + +## 测试 + +注册表测试覆盖语法边界、不可变规范化、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知回滚、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 + +TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 + +## 考虑过的替代方案 + +- **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改每个前端。 +- **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 +- **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。 +- **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 +- **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 +- **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。 +- **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。 + +## 后果 + +- 命令生产者是普通的可移除插件,TUI 与 ACP 共享一个经过校验的目录和分派契约。 +- 智能体特定定义保留现有扁平作用域与遮蔽语义,不引入核心到 UI 的依赖。 +- 未知斜杠输入与命令输出是确定性 UI 行为,直接模型 token 成本为零。 +- ACP 客户端在创建、加载、注册和 HMR 移除后收到当前的逐会话快照。 +- 直接命令取消与模型轮次取消彼此隔离。 + +## 已知限制与延期工作 + +- 输入元数据仅为 ACP 当前的非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续协议扩展。 +- 通用命令输出仅实时存在,TUI 重启或 ACP 重新连接后不会重建。 +- 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 +- 已发布的行式 `dsh-stdio` 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 和 ACP 消费它。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index ce3fe8acef..50d688089e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl index d9d2632bd0..875ff05303 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 4111ecc8de..6c6e1d6158 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl index 11178b9bc7..1e1131398b 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index 4146e8804d..0277ef2f90 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index f3bd0b345c..2e06d6839a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl index 3d25d176ac..a41a9b8b3b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 47dc73536f..4e2f95accf 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl index 321c5499a2..50c05a4d18 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 540eb2338a..80ab62b674 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index 92743b8133..e169016a1c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index c8a9b320f0..5a60174abb 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index fab47cc857..451fe781dd 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index d92ed5520b..39ddf764de 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 44ce1184e9..2f2e1bc667 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 712e8e5c3b..3fed936cfc 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index d06162a005..51bb5f7c65 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 270d1ace7c..9e0d057b2a 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 1cac540a29..927bb99975 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index fb4f7cbbc5..1390e424d6 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index af42092168..89e476b3e8 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index bed3d3a03a..b13a498bc0 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index 48b5df1ac2..5acbeb7202 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index 74f4b9ea10..623127e083 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 29023a8d45..19ec84b738 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index 15bf48cb81..3f75a15f57 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index 39cf7df191..bf478888b4 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index 7870b73dc2..b8b6abe2c3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index cece2795f7..c8c34ab6ec 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 1a0e83d191..8e303806a9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 29023a8d45..19ec84b738 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index 15a91d1af3..6107da23da 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index 821d648791..ef02915d01 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl index 291525f825..e3a5aacbeb 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 9a55a86f02..57dd33f320 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl index 8680db3d30..53048fa11d 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index 358e81f076..2232cd3a1c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index 8469933a94..bc40beb3fe 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index a1b4b8c0cb..8436971982 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index e2941dd851..a332ea7a7c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index e5cc8bfa90..ded1ec01cc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index bd4fb81d4a..d25e78d0db 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 2b77e856e6..d1f79bacac 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index c717c3182a..dc7b4dbe25 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index 8771e50182..0cae765592 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 2c19d8feb9..dce9237834 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 03f482bcc6..73604add34 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl index a55c6d6e01..8108f47bb0 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 4e8db74ba6..ff2610f1f5 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,4 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index c0537021e8..340d129bcf 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -9,6 +9,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' +import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -179,6 +180,7 @@ async function mountScenarioContext( await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) await ctx.plugin(ToolWorkflow) + await ctx.plugin(CommandService) if (scenario.composition === 'code' || scenario.composition === 'advanced') { await ctx.plugin(WorkerCodeRuntime, {}) } diff --git a/knip.json b/knip.json index c97ea4edea..bb2fb23458 100644 --- a/knip.json +++ b/knip.json @@ -126,6 +126,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/commands": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/stdio-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb3dc9c3a5..d2e88e0707 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -198,6 +198,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'commands', + summary: 'Human-command registry.', + methods: [ + { + signature: 'register(definition: CommandDefinition): () => void', + jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', + }, + { + signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]', + jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */', + }, + { + signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined', + jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */', + }, + { + signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */', + }, + ], + }, { key: 'compact', summary: 'Abstract compaction service.', @@ -786,6 +808,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */', summary: 'Ask composed answerers for one decision.', }, + { + name: 'commands/change', + mode: 'emit', + signature: '\'commands/change\'(): void', + jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * @mode emit\n */', + summary: 'A command was registered or unregistered.', + }, { name: 'fs/edit-intent', mode: 'waterfall', @@ -1108,6 +1137,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CollectedOutput', declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', }, + { + name: 'CommandDefinition', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + }, + { + name: 'CommandDescriptor', + declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}', + }, + { + name: 'CommandInputDescriptor', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n}', + }, + { + name: 'CommandInvocation', + declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + }, + { + name: 'CommandResult', + declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', + }, + { + name: 'CommandSurface', + declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});', + }, { name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', diff --git a/packages/examples/README.md b/packages/examples/README.md index 5703039d94..58cffeabac 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,9 +5,9 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + command registry + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | `agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 1dbda9a08a..9ff052036c 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -11,6 +11,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | Plugin | Why | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | +| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 28d057c0a0..f74617f306 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-demo", - "description": "ACP server app: the agent-spine-demo bundle + JSONL persistence + the ACP bridge (no stdout logger, no hmr, no pre-created agents), with a bin to boot a leaf cordis.yml over JSON-RPC stdio", + "description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin", "version": "0.0.1", "private": true, "type": "module", @@ -34,6 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -47,6 +48,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 6785e5f957..e3eac668a4 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -1,7 +1,7 @@ /** * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}), - * JSONL session persistence, and the {@link @deepseek-ai/dsh-acp} bridge. It - * writes nothing to stdout. + * human-command registry, JSONL session persistence, and the + * {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout. * It pre-creates no agents and leaves adapters, executors, and optional tools to * the leaf, which must likewise avoid stdout loggers. Named exports are * required so Loader retains this plugin's `Config` schema (see @@ -12,6 +12,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' +import CommandService from '@deepseek-ai/dsh-commands' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -87,6 +88,7 @@ export const Config: z = z.object({ * from the provider/model pair. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { + ctx.plugin(CommandService) ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index b0e537574a..78090f6273 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../ui/acp" }, + { + "path": "../../ui/commands" + }, { "path": "../../core/agent" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index ba4fc10101..38a24918af 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -11,6 +11,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | +| `@deepseek-ai/dsh-commands` | the human-command registry consumed by the TUI front door and optional command plugins | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | @@ -79,7 +80,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ### Composed terminal agent request -**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. +**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands remain outside model context. **Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 94554e3f8e..b8285dc351 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", + "description": "Terminal chat app: agent spine + human commands + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 531c4a3d5e..fecdaf8547 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -1,9 +1,9 @@ /** * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline - * presentation, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and one pre-created agent whose exact shared - * agent/session identity the selected UI drives under its `main` display label. + * coupled front-door cluster a terminal chat needs — the command registry, + * TTY-selected pi-tui/readline presentation, JSONL session persistence, the + * user-interaction seam with its `ask_user_question` tool, and one pre-created + * agent whose exact shared identity the selected UI drives as `main`. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -16,6 +16,7 @@ import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import CommandService from '@deepseek-ai/dsh-commands' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -145,6 +146,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) const mode = resolveTerminalMode(config.ui, isTTY) if (mode === 'readline') ctx.plugin(ConsoleExporter) + ctx.plugin(CommandService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(UserInteractionService) if (mode === 'tui') { diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index fc6711ffb9..61646af70f 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/session" }, + { + "path": "../../ui/commands" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 5b5608cdf0..f893f56e56 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities, All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts. -Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. +Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge. `SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically. diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index e4ff11af20..4e72ba5a41 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -73,6 +73,10 @@ class AppOption extends FeatureOption { case 'acp': return new ProjectContribution([ ...appProjectResources(profile, this.id), + ...npmCordisConfigEntry(ID, { + id: 'commands', + name: '@deepseek-ai/dsh-commands', + }), ...npmCordisConfigEntry(ID, { id: 'user-interaction', name: '@deepseek-ai/dsh-user-interaction', diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 655b308911..b0a19477a0 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -283,6 +283,7 @@ describe('SdkProject and ProjectEditSession', () => { edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp'])) const acp = (await edit.commit()).project expect(acp.profile.runInterface).toBe('acp') + expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' }) expect(acp.packageManifest().scripts).toMatchObject({ dev: 'dsh-sdk dev index.ts', start: 'dsh-sdk start index.js', diff --git a/packages/ui/README.md b/packages/ui/README.md index 3dd26d80a8..0e7cec58aa 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are ** | Package | Role | ctx key | |---|---|---| | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | +| `commands/` | Human-command registry: discovery metadata, scoped shadowing, surface filtering, cancellation, and direct UI dispatch | `ctx.commands` | | `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` | | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | @@ -14,7 +15,7 @@ Integrations that expose the agent to an external editor or client. These are ** | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. [`commands`](commands/README.md) is their human-only discovery and dispatch plane; command input and output do not become model messages. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 4e19313302..5838e45679 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | ACP method | Harness seam | Notes | |---|---|---| | `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` | -| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | -| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | -| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | -| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | +| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | +| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands | +| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session | +| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another | | `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | @@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). +## Human commands + +After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. + +ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command RFC](../../../docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). + ## Session config options The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only. @@ -98,6 +104,12 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa **Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. +### Human commands + +**What the model sees**: Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests. + +**Token effect**: Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost. + ### Human answers and permission decisions **What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. @@ -128,3 +140,4 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa - **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`. - **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). - **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam. +- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 34e3caae8a..7eacea9cd3 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th ## At a glance -The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). +The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary). ## 1. Agent methods (client → agent) @@ -23,8 +23,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i | `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. | | `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. | | `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. | -| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. | -| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. | +| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. | +| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. | | `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). | | `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. | | model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. | @@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). | | `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. | | `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). | -| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. | +| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. | | `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. | | `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). | | `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). | @@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl 1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`. 2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries. -3. **Slash commands** (`available_commands_update`). -4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). -5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). -6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). -7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. +3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`). +4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path). +5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`). +6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access. ## Out of scope diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..d56a573698 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index e20cd40f10..9fda202688 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -18,6 +18,7 @@ import { RequestError, type Agent as AcpAgent, type AuthenticateRequest, + type AvailableCommand, type CancelNotification, type ContentBlock as AcpContentBlock, type CreateElicitationRequest, @@ -45,6 +46,7 @@ import { import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-commands' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -76,13 +78,22 @@ import { export const name = 'acp' // Interface services back loading, presentation, interaction, and prompt assembly. -export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] +export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) @@ -259,6 +270,8 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined + /** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */ + commandAbort: AbortController | undefined /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -273,6 +286,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents + const commands = ctx.commands const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger @@ -467,6 +481,30 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } + /** Project the effective registry view onto ACP discovery metadata. */ + const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({ + name: command.name, + description: command.description, + ...command.input === undefined ? {} : { input: { hint: command.input.hint } }, + })) + + /** Push the protocol's full-snapshot command catalog for one live session. */ + const notifyCommands = (rec: SessionRecord): void => { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: availableCommands(rec.agent), + }, + }) + } + + // Registration and HMR removal can affect global or one scoped view; refresh + // every bridge-owned session and let the registry resolve each exact agent. + ctx.on('commands/change', () => { + for (const rec of sessions.values()) notifyCommands(rec) + }) + /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */ const settlePrompt = (rec: SessionRecord, reason: StopReason): void => { const inflight = rec.inflight @@ -680,8 +718,10 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled: terminalOutputCap, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, }) + notifyCommands(requireSession(sessionId)) const configOptions = configOptionsFor(handle.agent, directory) return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} } }, @@ -762,6 +802,7 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalEnabled, target, inflight: undefined, + commandAbort: undefined, pendingSwitches: {}, } sessions.set(sessionId, record) @@ -786,6 +827,7 @@ export function apply(ctx: Context, config: AcpConfig): void { for (const event of agent.session.events) { streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal) } + notifyCommands(record) const configOptions = configOptionsFor(agent, directory) return configOptions.length > 0 ? { configOptions } : {} } finally { @@ -796,7 +838,7 @@ export function apply(ctx: Context, config: AcpConfig): void { async prompt(params: PromptRequest): Promise { assertOpen() const rec = requireSession(SessionId(params.sessionId)) - if (rec.inflight !== undefined) { + if (rec.inflight !== undefined || rec.commandAbort !== undefined) { throw invalidParams('a prompt is already in flight for this session') } if (promptHasUnsupportedContent(params.prompt)) { @@ -809,6 +851,52 @@ export function apply(ctx: Context, config: AcpConfig): void { // waiting for a settle that never comes. throw invalidParams('empty prompt') } + // ACP command prompts may carry additional supported content blocks. + // The same lossless flattening used for model prompts supplies their + // unstructured command input; unsupported kinds were rejected above. + const commandLine = text.startsWith('/') ? text : undefined + if (commandLine !== undefined) { + const controller = new AbortController() + rec.commandAbort = controller + try { + const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal) + if (result !== undefined && result.text !== undefined && result.text !== '') { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: result.kind === 'error' ? `Error: ${result.text}` : result.text, + }, + }, + }) + } else if (result === undefined) { + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: unknown command: ${commandLine}` }, + }, + }) + } + return { stopReason: 'end_turn' } + } catch (error: unknown) { + if (controller.signal.aborted) return { stopReason: 'cancelled' } + const rendered = renderThrown(error) + logger.warn(`acp: command failed: ${rendered}`) + notify({ + sessionId: rec.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `Error: command failed: ${rendered}` }, + }, + }) + return { stopReason: 'end_turn' } + } finally { + rec.commandAbort = undefined + } + } // Install the in-flight slot BEFORE send() (send does not synchronously // flip status to running; the session/event listener records the turn // number and settle/rejects it). Capture the log length now as the @@ -835,8 +923,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's // resolution onto a later observer path, changing its timing. - rec.agent.cancel('session/cancel') - settlePrompt(rec, 'cancelled') + if (rec.commandAbort !== undefined) { + rec.commandAbort.abort(new Error('session/cancel')) + } else { + rec.agent.cancel('session/cancel') + settlePrompt(rec, 'cancelled') + } return Promise.resolve() }, @@ -950,6 +1042,7 @@ export function apply(ctx: Context, config: AcpConfig): void { quiescing = (async () => { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') + rec.commandAbort?.abort(new Error('ACP connection closed')) // Per-agent dispose (the AgentHandle disposer): unregister this agent, // stop its loop (sets disposed + aborts the in-flight step), await // quiescence (the loop exit + final flush), and remove its session — so diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts new file mode 100644 index 0000000000..487b4b59f0 --- /dev/null +++ b/packages/ui/acp/tests/commands.spec.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +function commandUpdates(harness: BridgeHarness, sessionId: string) { + return harness.sessionUpdates.filter(update => update.sessionId === sessionId + && update.update.sessionUpdate === 'available_commands_update') +} + +function messageText(harness: BridgeHarness, sessionId: string): string { + return harness.sessionUpdates + .filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk') + .map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? update.content.text : '') + .join('') +} + +describe('ACP plugin commands', () => { + let storageDir: string + let harness: BridgeHarness | undefined + + beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) }) + afterEach(async () => { + if (harness !== undefined) await harness.dispose() + harness = undefined + await rm(storageDir, { recursive: true, force: true }) + }) + + it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + }], + }) + + const dispose = harness.ctx.commands.register({ + name: 'alpha', + description: 'Alpha command', + surfaces: ['acp'], + handler: () => ({ kind: 'success' }), + }) + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'alpha' }, { name: 'inspect' }], + }) + }) + dispose() + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'inspect' }], + }) + }) + }) + + it('re-advertises commands after loading a persisted session', async () => { + const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] }) + await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] }) + await live.dispose() + + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }) + + expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({ + availableCommands: [{ name: 'loaded', description: 'Loaded command' }], + }) + }) + + it('executes a known single-text command directly and never sends it to the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' })) + harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen }) + harness.ctx.commands.register({ + name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }), + }) + harness.ctx.commands.register({ + name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }), + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const response = await harness.client.prompt({ + sessionId, + prompt: [{ type: 'text', text: '/direct raw args ' }], + }) + + expect(response.stopReason).toBe('end_turn') + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' })) + expect(messageText(harness, sessionId)).toContain('DIRECT RESULT') + const updatesAfterText = harness.sessionUpdates.length + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] }) + expect(harness.sessionUpdates).toHaveLength(updatesAfterText) + expect(harness.adapter.requests).toHaveLength(0) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0) + }) + + it('renders expected command errors and rejects unknown slash commands without model fallback', async () => { + harness = await makeBridgeHarness({ storageDir }) + harness.ctx.commands.register({ + name: 'denied', + description: 'Deny directly', + handler: () => ({ kind: 'error', text: 'not allowed now' }), + }) + harness.ctx.commands.register({ + name: 'throws', + description: 'Throw an ordinary error', + handler: () => { throw new Error('handler exploded') }, + }) + harness.ctx.commands.register({ + name: 'hostile', + description: 'Throw a hostile value', + handler: () => { + throw { toString(): string { throw new Error('coercion exploded') } } + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + + expect(messageText(harness, sessionId)).toContain('Error: not allowed now') + expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input') + expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded') + expect(messageText(harness, sessionId)).toContain('Error: command failed: ') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('flattens supported command prompt blocks without invoking the model', async () => { + harness = await makeBridgeHarness({ storageDir }) + const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' })) + harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ + sessionId, + prompt: [ + { type: 'text', text: '/direct' }, + { type: 'text', text: ' extra' }, + { type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' }, + ], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(command).toHaveBeenCalledWith(expect.objectContaining({ + rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n', + })) + expect(messageText(harness, sessionId)).toContain('combined') + expect(harness.adapter.requests).toHaveLength(0) + }) + + it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + harness.ctx.commands.register({ + name: 'wait', + description: 'Wait for cancellation', + handler: ({ signal }) => { + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true }) + }) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }) + await ready + await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })) + .rejects.toThrow(/already in flight/) + await harness.client.cancel({ sessionId: a.sessionId }) + + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + expect(messageText(harness, a.sessionId)).not.toContain('late abort result') + }) + + it('aborts an in-flight command when the ACP bridge is disposed', async () => { + harness = await makeBridgeHarness({ storageDir }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + harness.ctx.commands.register({ + name: 'wait-dispose', + description: 'Wait for bridge disposal', + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise(() => {}) + }, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] }) + await ready + await harness.acpFiber.dispose() + + expect(commandSignal?.aborted).toBe(true) + await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' }) + }) + + it('resolves scoped command catalogs and execution independently per session', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agentA = harness.ctx.agents.get(SessionId(a.sessionId)) + if (agentA === undefined) throw new Error('session A has no agent') + await agentA.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'private', description: 'Only session A', surfaces: ['acp'], + handler: () => ({ kind: 'success', text: 'A ONLY' }), + }) + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] }) + }) + expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] }) + await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] }) + expect(messageText(harness, a.sessionId)).toContain('A ONLY') + expect(messageText(harness, b.sessionId)).toContain('unknown command') + }) +}) diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 1796cbf868..9b4c0c317a 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, import { LlmAdapter } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import CommandService from '@deepseek-ai/dsh-commands' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: { await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' }, }) + await ctx.plugin(CommandService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..428e68e01c 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" }, diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md new file mode 100644 index 0000000000..6821d25ed2 --- /dev/null +++ b/packages/ui/commands/README.md @@ -0,0 +1,31 @@ +# @deepseek-ai/dsh-commands + +Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration RFC](../../../docs/rfc/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping. + +## Service contract + +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal emits `commands/change` so live adapters can refresh discovery. + +`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. + +`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. + +Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. + +## Composition + +The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly. + +## Model Experience + +### Direct human commands + +**What the model sees**: Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. + +**Token effect**: Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs. + +## Known Limitations and Deferred Work + +- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns. +- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect. +- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems. diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json new file mode 100644 index 0000000000..ef7e54e1c1 --- /dev/null +++ b/packages/ui/commands/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-commands", + "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts new file mode 100644 index 0000000000..97ec0c3587 --- /dev/null +++ b/packages/ui/commands/src/index.ts @@ -0,0 +1,319 @@ +/** + * Plugin-owned human-command registry shared by interactive UI adapters. + * @module @deepseek-ai/dsh-commands + */ + +import { Context, Service } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' + +export const name = 'commands' + +const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u +const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u +const DEFAULT_SURFACES = ['tui', 'acp'] as const + +/** A UI adapter capable of listing and executing human commands. */ +export type CommandSurface = 'tui' | 'acp' | (string & {}) + +/** Immutable command input metadata compatible with ACP unstructured input. */ +export interface CommandInputDescriptor { + /** Placeholder shown before the user supplies free-form input. */ + readonly hint: string +} + +/** Invocation passed to one registered command handler. */ +export interface CommandInvocation { + /** Exact agent whose human-facing surface received the command. */ + readonly agent: Agent + /** UI adapter that dispatched the command. */ + readonly surface: CommandSurface + /** Exact text following the registered command name, including separator whitespace. */ + readonly rawInput: string + /** Cancellation signal owned by the dispatching UI request. */ + readonly signal: AbortSignal +} + +/** Expected command outcome rendered directly by the dispatching UI. */ +export type CommandResult = + | { readonly kind: 'success'; readonly text?: string } + | { readonly kind: 'error'; readonly text: string } + +/** Plugin-owned command registration. */ +export interface CommandDefinition { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces exposing this command; omission means both shipped surfaces. */ + readonly surfaces?: readonly CommandSurface[] + /** Execute against the receiving agent without sending the command to the model. */ + readonly handler: (invocation: CommandInvocation) => CommandResult | Promise +} + +/** Handler-free immutable command view returned to UI adapters. */ +export interface CommandDescriptor { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Human-readable summary used in discovery UI. */ + readonly description: string + /** Optional free-form input hint advertised to capable clients. */ + readonly input?: CommandInputDescriptor + /** Surfaces on which this definition is visible. */ + readonly surfaces: readonly CommandSurface[] +} + +/** Syntactically valid slash command before registry resolution. */ +export interface ParsedCommand { + /** Lowercase command name without the leading slash. */ + readonly name: string + /** Exact text following the command name. */ + readonly rawInput: string +} + +interface RegisteredCommand { + readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] } + readonly descriptor: CommandDescriptor +} + +declare module 'cordis' { + interface Context { + commands: CommandService + } + + interface Events { + /** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ + 'commands/change'(): void + } +} + +/** + * Parse an exact slash command without normalizing its trailing input. + * + * @param line - Complete candidate command line. + * @returns The parsed command, or `undefined` when the line is not a command. + */ +export function parseCommand(line: string): ParsedCommand | undefined { + const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line) + if (match === null) return undefined + const name = match[1] + /* v8 ignore next -- the first capture is required whenever the regular expression matches */ + if (name === undefined) return undefined + return Object.freeze({ name, rawInput: line.slice(match[0].length) }) +} + +/** Convert arbitrary abort reasons to one stable rejected Error. */ +function abortError(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') +} + +/** Stop awaiting an uncooperative handler once its owning UI request aborts. */ +function withAbort(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(abortError(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error instanceof Error + ? error + : new Error('command handler rejected with a non-Error value')) + }, + ) + }) +} + +/** Reject invalid command metadata before it can reach a UI protocol. */ +function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { + if (!COMMAND_NAME.test(definition.name)) { + throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`) + } + if (definition.description.trim().length === 0) { + throw new TypeError(`command "${definition.name}" description must not be empty`) + } + if (typeof definition.handler !== 'function') { + throw new TypeError(`command "${definition.name}" handler must be a function`) + } + const input = definition.input === undefined + ? undefined + : Object.freeze({ hint: definition.input.hint }) + if (input !== undefined && input.hint.trim().length === 0) { + throw new TypeError(`command "${definition.name}" input hint must not be empty`) + } + const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] + if (surfaces.length === 0) { + throw new TypeError(`command "${definition.name}" must expose at least one surface`) + } + const unique = new Set() + for (const surface of surfaces) { + if (!SURFACE_NAME.test(surface)) { + throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`) + } + if (unique.has(surface)) { + throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`) + } + unique.add(surface) + } + const frozenSurfaces = Object.freeze(surfaces) + const normalized = Object.freeze({ + name: definition.name, + description: definition.description, + ...input === undefined ? {} : { input }, + surfaces: frozenSurfaces, + handler: definition.handler, + }) + const descriptor = Object.freeze({ + name: normalized.name, + description: normalized.description, + ...normalized.input === undefined ? {} : { input: normalized.input }, + surfaces: normalized.surfaces, + }) + return { definition: normalized, descriptor } +} + +/** Validate and detach an untrusted handler result at the registry boundary. */ +function normalizeResult(command: string, value: unknown): CommandResult { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + throw new TypeError(`command "${command}" handler must return a CommandResult`) + } + const result = value as { kind?: unknown; text?: unknown } + if (result.kind === 'success') { + if (result.text !== undefined && typeof result.text !== 'string') { + throw new TypeError(`command "${command}" success text must be a string when supplied`) + } + return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text }) + } + if (result.kind === 'error') { + if (typeof result.text !== 'string' || result.text.trim().length === 0) { + throw new TypeError(`command "${command}" error text must be a non-empty string`) + } + return Object.freeze({ kind: 'error', text: result.text }) + } + throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`) +} + +/** + * Human-command registry. Plain-context definitions are global; definitions + * registered through a command-injected child of an agent context shadow + * globals for that agent. + */ +export class CommandService extends Service { + private readonly global = new Map() + private readonly scoped = new Map>() + + constructor(ctx: Context) { + super(ctx, 'commands') + } + + /** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ + register(definition: CommandDefinition): () => void { + const scope = scopeOf(this.ctx) + const registered = normalizeDefinition(definition) + const dispose = this.ctx.effect(function* (this: CommandService) { + const layer = scope === undefined ? this.global : this.layerFor(scope) + if (layer.has(registered.definition.name)) { + throw new Error(scope === undefined + ? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)` + : `command "${registered.definition.name}" is already registered in this scope`) + } + layer.set(registered.definition.name, registered) + yield () => { + layer.delete(registered.definition.name) + if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) + this.ctx.emit('commands/change') + } + this.ctx.emit('commands/change') + }.bind(this), 'commands.register()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order + return dispose + } + + /** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ + list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] { + return Object.freeze([...this.view(agent).values()] + .filter(command => command.definition.surfaces.includes(surface)) + .map(command => command.descriptor) + // Names are unique in the effective view, so equality is impossible. + .sort((left, right) => left.name < right.name ? -1 : 1)) + } + + /** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ + find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined { + const command = this.view(agent).get(name) + return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined + } + + /** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ + async execute( + agent: Agent, + surface: CommandSurface, + line: string, + signal: AbortSignal, + ): Promise { + const parsed = parseCommand(line) + if (parsed === undefined) return undefined + const command = this.view(agent).get(parsed.name) + if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined + if (signal.aborted) throw abortError(signal) + const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal }) + const output = command.definition.handler(invocation) + return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) + } + + /** Resolve global definitions followed by exact scoped shadows. */ + private view(agent: Agent): Map { + const visible = new Map(this.global) + for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command) + return visible + } + + /** Create the registration layer for one agent scope on demand. */ + private layerFor(scope: ScopeKey): Map { + let layer = this.scoped.get(scope) + if (layer === undefined) { + layer = new Map() + this.scoped.set(scope, layer) + } + return layer + } +} + +export default CommandService diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts new file mode 100644 index 0000000000..67f9d3d0e2 --- /dev/null +++ b/packages/ui/commands/tests/commands.spec.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import type { Scope } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' + +function command(name: string, text = `ran:${name}`): CommandDefinition { + return { + name, + description: `command ${name}`, + handler: () => ({ kind: 'success', text }), + } +} + +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + return ctx +} + +/** Mint a scope whose key is sufficient for registry lookup and invocation. */ +async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> { + const agent = { id: name as SessionId } as Agent + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] })) + return { scope, agent } +} + +describe('parseCommand()', () => { + it.each([ + ['/goal', { name: 'goal', rawInput: '' }], + ['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }], + ['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }], + ['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }], + ] as const)('parses %j without normalizing trailing input', (line, expected) => { + expect(parseCommand(line)).toEqual(expected) + }) + + it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => { + expect(parseCommand(line)).toBeUndefined() + }) +}) + +describe('CommandService', () => { + it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const definition: CommandDefinition = { + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + handler: () => ({ kind: 'success' }), + } + ctx.commands.register(definition) + + const listed = ctx.commands.list(agent, 'acp') + expect(listed).toEqual([{ + name: 'inspect', + description: 'Inspect state', + input: { hint: '' }, + surfaces: ['tui', 'acp'], + }]) + expect(Object.isFrozen(listed)).toBe(true) + expect(Object.isFrozen(listed[0])).toBe(true) + expect(Object.isFrozen(listed[0]?.input)).toBe(true) + expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true) + expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined() + expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined() + }) + + it('sorts distinct effective command names', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('zeta')) + ctx.commands.register(command('alpha')) + ctx.commands.register(command('middle')) + expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) + }) + + it('uses agent-scoped shadows and removes them with their scope', async () => { + const ctx = await mount() + const { scope, agent } = await mintAgentScope(ctx, 'a') + const other = { id: 'other' as SessionId } as Agent + ctx.commands.register(command('shared', 'global')) + scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] }) + + expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared']) + expect(ctx.commands.list(agent, 'acp')).toEqual([]) + expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared']) + expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal)) + .toEqual({ kind: 'success', text: 'scoped' }) + + await scope.dispose() + expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global') + }) + + it('rejects duplicates within one layer while allowing a scoped shadow', async () => { + const ctx = await mount() + const { scope } = await mintAgentScope(ctx, 'a') + ctx.commands.register(command('same')) + expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/) + scope.ctx.commands.register(command('same')) + expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/) + }) + + it('emits on registration and disposal and rolls back when notification fails', async () => { + const ctx = await mount() + const changed = vi.fn() + ctx.on('commands/change', changed) + const dispose = ctx.commands.register(command('live')) + dispose() + dispose() + expect(changed).toHaveBeenCalledTimes(2) + + const explode = ctx.on('commands/change', () => { throw new Error('observer failed') }) + expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed') + explode() + const { agent } = await mintAgentScope(ctx, 'a') + expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined() + }) + + it('passes exact invocation context and detaches valid handler results', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' })) + ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen }) + const controller = new AbortController() + + const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal) + + expect(result).toEqual({ kind: 'success', text: 'ok' }) + expect(Object.isFrozen(result)).toBe(true) + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ + agent, + surface: 'acp', + rawInput: ' untouched ', + signal: controller.signal, + })) + await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined() + }) + + it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + let release!: (result: { kind: 'success'; text: string }) => void + ctx.commands.register({ + name: 'wait', + description: 'Wait', + handler: () => new Promise((resolve) => { release = resolve }), + }) + const running = new AbortController() + const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal) + running.abort('operator cancelled command') + await expect(promise).rejects.toThrow('operator cancelled command') + release({ kind: 'success', text: 'late' }) + + const already = new AbortController() + already.abort(new Error('already gone')) + await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone') + + const defaultReason = new AbortController() + defaultReason.abort({ source: 'test' }) + await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + }) + + it('propagates an asynchronously rejected handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'reject', + description: 'Reject', + handler: () => Promise.reject(new Error('handler rejected')), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal)) + .rejects.toThrow('handler rejected') + + ctx.commands.register({ + name: 'reject-value', + description: 'Reject a non-Error value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization + handler: () => Promise.reject('not an Error'), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) + .rejects.toThrow('command handler rejected with a non-Error value') + }) + + it('observes an abort triggered synchronously inside the handler', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + const controller = new AbortController() + ctx.commands.register({ + name: 'self-abort', + description: 'Abort before returning', + handler: () => { + controller.abort('aborted in handler') + return { kind: 'success' } + }, + }) + await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal)) + .rejects.toThrow('aborted in handler') + }) + + it('returns a detached expected-error result', async () => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'denied', + description: 'Denied', + handler: () => ({ kind: 'error', text: 'not now' }), + }) + const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal) + expect(result).toEqual({ kind: 'error', text: 'not now' }) + expect(Object.isFrozen(result)).toBe(true) + + ctx.commands.register({ + name: 'silent', + description: 'No output', + handler: () => ({ kind: 'success' }), + }) + const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal) + expect(silent).toEqual({ kind: 'success' }) + expect(Object.isFrozen(silent)).toBe(true) + }) + + it.each([ + [{ ...command('Bad') }, /command name/], + [{ ...command('empty-description'), description: ' ' }, /description/], + [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/], + [{ ...command('no-surface'), surfaces: [] }, /at least one surface/], + [{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/], + [{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/], + [{ ...command('bad-handler'), handler: undefined }, /handler/], + ] as const)('rejects invalid definition %#', async (definition, expected) => { + const ctx = await mount() + expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected) + }) + + it.each([ + [undefined, /CommandResult/], + [null, /CommandResult/], + [{}, /CommandResult/], + [{ kind: 'success', text: 1 }, /success text/], + [{ kind: 'error', text: '' }, /error text/], + [{ kind: 'error', text: 1 }, /error text/], + [{ kind: 'future', text: 'x' }, /unknown result kind/], + ] as const)('rejects malformed handler result %j', async (output, expected) => { + const ctx = await mount() + const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.register({ + name: 'broken', + description: 'Broken', + handler: () => output as never, + }) + await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected) + }) +}) diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json new file mode 100644 index 0000000000..478cc26479 --- /dev/null +++ b/packages/ui/commands/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + } + ] +} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index e426640ae5..9fad307802 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,13 +4,13 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature RFC](../../../docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot RFC](../../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. -This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. +This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. -While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; plugin commands for the `tui` surface join autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. ## Config @@ -37,7 +37,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t maxToolOutputLines: 12 ``` -Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color @@ -47,7 +47,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic ### Interactive prompt input -**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. +**What the model sees**: Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices. **Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..d470afeaf2 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -38,6 +39,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1c3fc1315c..2ace80165c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-commands' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -53,7 +54,7 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'userInteraction', 'tools'] +export const inject = ['agents', 'commands', 'userInteraction', 'tools'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { @@ -839,6 +840,7 @@ export function createTuiChat( const allToolCards = new Set() const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] + const commandControllers = new Set() let activeQuestion: PendingQuestion | undefined const welcome = config.welcome ?? 'ready.' @@ -1097,6 +1099,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true clearStatus() + for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) + commandControllers.clear() if (activeQuestion !== undefined) { const pending = activeQuestion activeQuestion = undefined @@ -1121,16 +1125,6 @@ export function createTuiChat( void shutdown(true) } - editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ - { name: 'help', description: 'Show keyboard shortcuts and commands' }, - { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, - { name: 'cancel', description: 'Cancel the active turn' }, - { name: 'reasoning', description: 'Toggle reasoning blocks' }, - { name: 'tools', description: 'Expand or collapse all tool cards' }, - { name: 'redraw', description: 'Invalidate components and redraw the terminal' }, - { name: 'exit', description: 'Exit after the active turn reaches idle' }, - ], agent.session.header.cwd ?? process.cwd())) - const toggleTools = (): void => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) @@ -1150,52 +1144,113 @@ export function createTuiChat( } const showHelp = (): void => { + const commandLines = ctx.commands.list(agent, 'tui').map((command) => { + const input = command.input === undefined ? '' : ` ${command.input.hint}` + return `/${command.name}${input} — ${command.description}` + }) chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', - '/help /clear /cancel /reasoning /tools /redraw /exit', + '', + ...commandLines, ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } + const refreshCommandAutocomplete = (): void => { + editor.setAutocompleteProvider(new CombinedAutocompleteProvider( + ctx.commands.list(agent, 'tui').map(command => ({ + name: command.name, + description: command.description, + })), + agent.session.header.cwd ?? process.cwd(), + )) + } + const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) + refreshCommandAutocomplete() + + // The agent scope is minted by agent-loop and intentionally inherits only + // that core plugin's dependencies. A child command producer declares its own + // UI-service dependency while retaining the parent agent scope and lifetime. + const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => { + commandCtx.commands.register({ + name: 'help', + description: 'Show keyboard shortcuts and commands', + surfaces: ['tui'], + handler: () => { showHelp(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'clear', + description: 'Clear the transcript view (session history is unchanged)', + surfaces: ['tui'], + handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'cancel', + description: 'Cancel the active turn', + surfaces: ['tui'], + handler: () => { + if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } + agent.cancel('cancelled from terminal') + return { kind: 'success', text: 'Cancellation requested.' } + }, + }) + commandCtx.commands.register({ + name: 'reasoning', + description: 'Toggle reasoning blocks', + surfaces: ['tui'], + handler: () => { toggleReasoning(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'tools', + description: 'Expand or collapse all tool cards', + surfaces: ['tui'], + handler: () => { toggleTools(); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'redraw', + description: 'Invalidate components and redraw the terminal', + surfaces: ['tui'], + handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, + }) + commandCtx.commands.register({ + name: 'exit', + description: 'Exit after the active turn reaches idle', + surfaces: ['tui'], + handler: () => { requestExit(); return { kind: 'success' } }, + }) + }) + + const runCommand = (text: string): void => { + const controller = new AbortController() + commandControllers.add(controller) + void ctx.commands.execute(agent, 'tui', text, controller.signal).then( + (result) => { + if (result === undefined) { + appendNotice(`Unknown command: ${text}`, 'warning') + } else if (result.text !== undefined && result.text !== '') { + appendNotice(result.text, result.kind === 'error' ? 'error' : 'info') + } + }, + (error: unknown) => { + if (!disposed) { + appendNotice(`Command failed: ${renderThrown(error)}`, 'error') + } + }, + ).finally(() => { commandControllers.delete(controller) }) + } + editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return editor.addToHistory(text) editor.setText('') - switch (text) { - case '/help': - showHelp() - return - case '/clear': - chat.clear() - requestRender() - return - case '/cancel': - if (agent.status === 'running') agent.cancel('cancelled from terminal') - else appendNotice('The agent is already idle.') - return - case '/reasoning': - toggleReasoning() - return - case '/tools': - toggleTools() - return - case '/redraw': - ui.invalidate() - ui.requestRender(true) - return - case '/exit': - requestExit() - return - default: - if (text.startsWith('/')) { - appendNotice(`Unknown command: ${text}`, 'warning') - return - } + if (value.startsWith('/')) { + runCommand(value) + return } if (agent.status === 'disposed') { appendNotice(`Agent "${agent.id}" is disposed.`, 'error') @@ -1273,6 +1328,7 @@ export function createTuiChat( const detachListeners = (): void => { removeInputListener() + disposeCommandChanges() disposeSessionEvents() disposeStatus() disposeError() @@ -1286,6 +1342,7 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() + void commandFiber.dispose() clearStatus() disposeUserInteraction() ui.stop() @@ -1296,6 +1353,7 @@ export function createTuiChat( async dispose(): Promise { detachListeners() await shutdown(false) + await commandFiber.dispose() }, } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..9d0520503b 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -47,6 +48,7 @@ export async function createTuiTestHarness { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt index 05809dea0c..5006083358 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=22 bufferRow=22 +cursor visible column=0 viewportRow=29 bufferRow=29 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt index fccfca604b..4a99d7e9e7 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=18 bufferRow=18 +cursor hidden column=1 viewportRow=25 bufferRow=25 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -29,24 +29,37 @@ buffer style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black -10| " /help /clear /cancel /reasoning /tools /redraw /exit " - style 1-52 fg=bright-black -11| -12| " Unknown command: /unknown-advanced-command " - style 1-42 fg=yellow -13| -14| " provider stream failed after partial output " +10| " " +11| " /cancel — Cancel the active turn " + style 1-32 fg=bright-black +12| " /clear — Clear the transcript view (session history is unchanged) " + style 1-65 fg=bright-black +13| " /exit — Exit after the active turn reaches idle " + style 1-47 fg=bright-black +14| " /help — Show keyboard shortcuts and commands " + style 1-44 fg=bright-black +15| " /reasoning — Toggle reasoning blocks " + style 1-36 fg=bright-black +16| " /redraw — Invalidate components and redraw the terminal " + style 1-55 fg=bright-black +17| " /tools — Expand or collapse all tool cards " + style 1-42 fg=bright-black +18| +19| " provider stream failed after partial output " style 1-43 fg=red -15| -16| " The previous process ended during this turn. " +20| +21| " The previous process ended during this turn. " style 1-44 fg=yellow -17| "────────────────────────────────────────────────────────────────────────────────────────────" +22| +23| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +24| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -18| " " +25| " " style 1-1 inverse -19| "────────────────────────────────────────────────────────────────────────────────────────────" +26| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" style 0-24 dim style 59-91 dim -21-31| +28-31| diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..bf63aeaeb9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -431,6 +432,84 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => { + const result = await setup() + const handler = vi.fn(({ rawInput }: CommandInvocation) => ({ + kind: 'success' as const, + text: `PLUGIN:${rawInput}`, + })) + result.ctx.commands.register({ + name: 'plugin-check', + description: 'Run a plugin command', + input: { hint: '' }, + surfaces: ['tui'], + handler, + }) + result.ctx.commands.register({ + name: 'plugin-fail', + description: 'Fail a plugin command', + surfaces: ['tui'], + handler: () => { throw new Error('plugin command exploded') }, + }) + + result.terminal.send('/plugin-check value ') + result.terminal.send('\r') + await tick() + + expect(handler).toHaveBeenCalledTimes(1) + const invocation = handler.mock.calls[0]?.[0] + expect(invocation?.agent).toBe(result.agent) + expect(invocation?.surface).toBe('tui') + // pi-tui's Editor owns terminal-line normalization and removes trailing + // spaces before onSubmit; the registry preserves the adapter-delivered line. + expect(invocation?.rawInput).toBe(' value') + expect(result.terminal.output).toContain('PLUGIN: value') + result.terminal.send('/plugin-fail') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Command failed: Error: plugin command exploded') + result.terminal.send('/help') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('/plugin-check — Run a plugin command') + expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help') + + await result.controller.dispose() + expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([ + 'plugin-check', + 'plugin-fail', + ]) + await result.ctx.fiber.dispose() + }) + + it('aborts an in-flight plugin command during TUI disposal', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let commandSignal: AbortSignal | undefined + result.ctx.commands.register({ + name: 'wait-plugin', + description: 'Wait until disposal', + surfaces: ['tui'], + handler: ({ signal }) => { + commandSignal = signal + started() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true }) + }) + }, + }) + + result.terminal.send('/wait-plugin') + result.terminal.send('\r') + await ready + await result.controller.dispose() + + expect(commandSignal?.aborted).toBe(true) + expect(result.terminal.output).not.toContain('late result') + await result.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') @@ -808,6 +887,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('main')) @@ -826,6 +906,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -854,6 +935,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -881,6 +963,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const terminal = new FakeTerminal() @@ -901,6 +984,7 @@ describe('terminal mounting', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const session = ctx.sessions.create(SessionId('failed-start-session')) @@ -913,6 +997,8 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') + await tick() + expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) @@ -930,6 +1016,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) ctx.provide('tools', { get: () => undefined } as never) const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..83ea611409 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/tools" }, + { + "path": "../commands" + }, { "path": "../user-interaction" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 728fa9ac33..28469ff9c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -825,6 +828,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2018,6 +2024,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local @@ -2085,6 +2094,21 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/commands: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/ui/jsonrpc: dependencies: schemastery: @@ -2211,6 +2235,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2565,6 +2592,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../packages/ui/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../packages/compact/compact diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2d3560ec5c..161c5c3460 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -18,6 +18,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 289a2e9778..73f533e34b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -74,6 +74,10 @@ export const LINK_MAP: Record = { GoalChanged: 'goal.md', GoalRef: 'goal.md', GoalView: 'goal.md', + CommandDefinition: 'commands.md', + CommandDescriptor: 'commands.md', + CommandResult: 'commands.md', + CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index bd0ecc18df..07f8d6315d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -145,6 +145,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-ask-user', 'stdio-demo', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, + { + key: 'commands', + pkg: 'commands', + title: 'Human command registry', + mode: 'core', + consumers: ['tui', 'acp'], + note: 'Plugins register direct human commands; TUI and ACP resolve each agent and surface without sending the invocation to the model.', + }, { key: 'skills', pkg: 'skill', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1092c6c0b0..867ee39bd4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,6 +39,14 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandSurface", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandResult", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDescriptor", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/commands.md", "symbol": "ParsedCommand", "source": "packages/ui/commands/src/index.ts" }, + { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 30e266da46..42140ab323 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,6 +25,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, diff --git a/tsconfig.json b/tsconfig.json index d64438c4ab..fd05bdd929 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/ui/commands" }, { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index 5fb3258805..db7dde160a 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -46,6 +46,10 @@ "text": "ctx.codeRuntime", "link": "/zh-CN/api/harness/code-runtime" }, + { + "text": "ctx.commands", + "link": "/zh-CN/api/harness/commands" + }, { "text": "ctx.compact", "link": "/zh-CN/api/harness/compact" diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md new file mode 100644 index 0000000000..6f8ed33dfa --- /dev/null +++ b/website/zh-CN/api/harness/commands.md @@ -0,0 +1,97 @@ + + +# ctx.commands + +`CommandService` — provided by `@deepseek-ai/dsh-commands`. + +Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L216) + +### ctx.commands.register(definition) + +```ts website-api +/** + * Register a global or calling-agent-scoped command. + * @param definition - discovery metadata, surface mask, and direct UI handler. + * @returns the exact effect disposer that unregisters this definition. + */ +register(definition: CommandDefinition): () => void +``` + +Register a global or calling-agent-scoped command. + +- `definition` — discovery metadata, surface mask, and direct UI handler. + +**Returns** the exact effect disposer that unregisters this definition. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L229) + +### ctx.commands.list(agent, surface) + +```ts website-api +/** + * List the effective immutable command descriptors for one agent and surface. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter requesting discovery metadata. + * @returns name-sorted descriptors after scoped shadowing and surface filtering. + */ +list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +``` + +List the effective immutable command descriptors for one agent and surface. + +- `agent` — exact receiving agent and scoped-layer key. +- `surface` — UI adapter requesting discovery metadata. + +**Returns** name-sorted descriptors after scoped shadowing and surface filtering. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L257) + +### ctx.commands.find(agent, surface, name) + +```ts website-api +/** + * Resolve one effective command definition. + * @param agent - exact receiving agent and scoped-layer key. + * @param surface - UI adapter performing the lookup. + * @param name - command name without a slash. + * @returns the scoped shadow or global definition when visible on the surface. + */ +find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +``` + +Resolve one effective command definition. + +- `agent` — exact receiving agent and scoped-layer key. +- `surface` — UI adapter performing the lookup. +- `name` — command name without a slash. + +**Returns** the scoped shadow or global definition when visible on the surface. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L272) + +### ctx.commands.execute(agent, surface, line, signal) + +```ts website-api +/** + * Parse and execute a known command without sending it to the model. + * @param agent - exact receiving agent. + * @param surface - dispatching UI adapter. + * @param line - complete slash-command line. + * @param signal - cancellation signal owned by the UI request. + * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + */ +async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +``` + +Parse and execute a known command without sending it to the model. + +- `agent` — exact receiving agent. +- `surface` — dispatching UI adapter. +- `line` — complete slash-command line. +- `signal` — cancellation signal owned by the UI request. + +**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L285) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index e959832c12..4c6286f097 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -2,7 +2,7 @@ # Harness events -Every event the harness packages declare on the cordis event bus (44 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). +Every event the harness packages declare on the cordis event bus (45 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate). ## agent/* @@ -472,6 +472,25 @@ Ask composed answerers for one decision. Return an outcome to claim the request [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-approval/src/index.ts#L31) +## commands/* + +### commands/change + +**Mode:** `emit` + +```ts website-api +/** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * @mode emit + */ +'commands/change'(): void +``` + +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L93) + ## fs/* ### fs/edit-intent From 4d965414b6074a28f18e5ba1871211d490999d3d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:24:09 +0800 Subject: [PATCH 039/132] docs(goal): describe cache effect --- packages/goal/goal/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 57f366c844..b59536ee1f 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -33,9 +33,17 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve ### Goal-state mutation -**What the model sees**: Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. +#### What the model sees -**Token effect**: Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. +Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. + +#### Token effect + +Every retained mutation adds one full snapshot to derived history until compaction shadows it. Full snapshots make each record independently inspectable but repeat the objective and lifecycle fields. + +#### KV Cache effect + +Append-only within an epoch: each mutation follows the reusable request prefix and preceding history. Compaction may replace the derived-history suffix and move the reusable boundary. ## Known Limitations and Deferred Work From 689e926c0ad55bcf94704fdc9749114d3401a6f6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:30:15 +0800 Subject: [PATCH 040/132] docs(goal): structure tool model experience --- packages/goal/tool-goal/README.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index a3987e32e1..b2332307f8 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -33,21 +33,37 @@ The value must be a positive safe integer. It supplies both the hard lower bound ### System prompt -**What the model sees**: A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. +#### What the model sees -**Token effect**: Small fixed input cost on every request where this plugin's prompt registration is in scope. +A fixed goal policy says when semantic human intent warrants creation, requires exact read-before-update refs, explains rearming after resume/fork, and limits completion/blocking claims. The configured threshold is interpolated into that guidance. -#### Goal policy +##### Goal policy ```markdown Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. ``` +#### Token effect + +Small fixed input cost on every request where this plugin's prompt registration is in scope. + +#### KV Cache effect + +Prefix-stable while the plugin scope, configured threshold, and guidance text are unchanged. Activation, disposal, or configuration changes may invalidate reuse from this prompt section. + ### Tool schemas and results -**What the model sees**: The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +#### What the model sees -**Token effect**: Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. + +#### Token effect + +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. + +#### KV Cache effect + +Schemas are prefix-stable while their definitions and visibility are unchanged. Calls, results, and resulting goal snapshots append after the reusable request prefix without invalidating earlier entries. ## Known Limitations and Deferred Work From 34fe1677757649be9fcbb93495c98944acab6c23 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:34:03 +0800 Subject: [PATCH 041/132] docs(goal): describe round cache behavior --- packages/goal/goal-session/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 0a73cfb75d..e0e74f749f 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -49,9 +49,17 @@ Cancellation is observe-before-act: the concrete loop emits `agent/cancel-reques ### Goal-round prompt -**What the model sees**: Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. +#### What the model sees -**Token effect**: One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. +Each admitted round is one retained user-role `` block naming the full objective and positive round number. Earlier human messages, goal-state snapshots, assistant output, and tool records remain in the same session history. + +#### Token effect + +One fixed instruction block plus the objective is added per admitted round. Later requests resend retained rounds until compaction shadows them; no fresh agent or copied conversation prefix is created. + +#### KV Cache effect + +Append-only within an epoch: each admitted round extends the existing conversation after its reusable prefix. Compaction may replace the derived-history suffix and move the reusable boundary. ## Known Limitations and Deferred Work From 207692bc161f004193af4bc750703e089cb59b71 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:55:33 +0800 Subject: [PATCH 042/132] feat(goal): add human goal command --- docs/architecture.md | 2 +- docs/config-catalog.md | 26 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/core.md | 2 +- docs/glossary.md | 3 +- docs/module-graph.md | 15 +- docs/rfc/INDEX.md | 1 + .../feature/2026-06-30-interception-seams.md | 2 +- .../2026-07-19-human-goal-command.i18n.yaml | 6 + .../feature/2026-07-19-human-goal-command.md | 72 +++++ .../2026-07-19-human-goal-command.zh.md | 72 +++++ ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 2 +- .../2026-07-19-model-facing-goal-tools.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 2 + .../advanced-toolchain/stdout.expected.jsonl | 2 +- .../system-prompt.expected.md | 24 ++ .../tool-schemas.expected.json | 69 +++++ .../bash-spill/stdout.expected.jsonl | 2 +- .../both-mode-turn/stdout.expected.jsonl | 2 +- .../both-mode-turn/system-prompt.expected.md | 24 ++ .../both-mode-turn/tool-schemas.expected.json | 69 +++++ .../cancel-tool-calls/stdout.expected.jsonl | 2 +- .../snapshots/cancel/stdout.expected.jsonl | 2 +- .../code-mode-turn/stdout.expected.jsonl | 2 +- .../code-mode-turn/system-prompt.expected.md | 24 ++ .../stdout.expected.jsonl | 2 +- .../system-prompt.expected.md | 24 ++ .../config-options/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../error-finish/stdout.expected.jsonl | 2 +- .../escalation-approved/stdout.expected.jsonl | 2 +- .../escalation-rejected/stdout.expected.jsonl | 2 +- .../snapshots/fs-edit/stdout.expected.jsonl | 2 +- .../fs-policy-reject/stdout.expected.jsonl | 2 +- .../fs-read-window/stdout.expected.jsonl | 2 +- .../snapshots/fs-read/stdout.expected.jsonl | 2 +- .../fs-terminal-card/stdout.expected.jsonl | 2 +- .../fs-write-overwrite/stdout.expected.jsonl | 2 +- .../snapshots/fs-write/stdout.expected.jsonl | 2 +- .../snapshots/goal-command-status/input.json | 7 + .../goal-command-status/session.jsonl | 1 + .../goal-command-status/stdout.expected.jsonl | 5 + .../snapshots/handshake/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../hook-cc-pretool-ask/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../model-switching/stdout.expected.jsonl | 2 +- .../model-switching/system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 138 ++++++++++ .../multi-turn/stdout.expected.jsonl | 2 +- .../parallel-tool-calls/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 138 ++++++++++ .../repeat-tool-guard/stdout.expected.jsonl | 2 +- .../skill-load/stdout.expected.jsonl | 2 +- .../skill-load/system-prompt.expected.md | 2 + .../skill-load/tool-schemas.expected.json | 69 +++++ .../subagent-fork/stdout.expected.jsonl | 2 +- .../subagent-mixed/stdout.expected.jsonl | 2 +- .../subagent-multi/stdout.expected.jsonl | 2 +- .../subagent-spawn/stdout.expected.jsonl | 2 +- .../snapshots/text-turn/stdout.expected.jsonl | 2 +- .../text-turn/system-prompt.expected.md | 2 + .../text-turn/tool-schemas.expected.json | 69 +++++ .../snapshots/todo-plan/stdout.expected.jsonl | 2 +- .../tool-call-turn/stdout.expected.jsonl | 2 +- .../workflow-run/stdout.expected.jsonl | 2 +- .../workspace-context/stdout.expected.jsonl | 2 +- .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 69 +++++ .../workspace-edit/stdout.expected.jsonl | 2 +- .../workspace-edit/system-prompt.expected.md | 2 + .../workspace-edit/tool-schemas.expected.json | 69 +++++ .../tests/contract-regressions.spec.ts | 2 +- packages/examples/README.md | 6 +- packages/examples/acp-demo/README.md | 4 +- packages/examples/acp-demo/package.json | 2 + packages/examples/acp-demo/src/index.ts | 8 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 31 ++- packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/agent-spine-demo/README.md | 11 +- .../examples/agent-spine-demo/package.json | 8 +- .../examples/agent-spine-demo/src/index.ts | 33 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 39 +++ .../examples/agent-spine-demo/tsconfig.json | 9 + packages/examples/stdio-demo/README.md | 5 +- packages/examples/stdio-demo/package.json | 2 + packages/examples/stdio-demo/src/index.ts | 7 + .../stdio-demo/tests/stdio-agent.spec.ts | 22 +- packages/examples/stdio-demo/tsconfig.json | 3 + packages/goal/README.md | 1 + packages/goal/command-goal/README.md | 56 ++++ packages/goal/command-goal/package.json | 38 +++ packages/goal/command-goal/src/index.ts | 165 ++++++++++++ .../command-goal/tests/command-goal.spec.ts | 254 ++++++++++++++++++ packages/goal/command-goal/tsconfig.json | 24 ++ packages/goal/tool-goal/README.md | 2 +- pnpm-lock.yaml | 48 ++++ python/sdk-runtime/package.json | 3 + tsconfig.json | 1 + 114 files changed, 1831 insertions(+), 88 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-human-goal-command.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-human-goal-command.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-human-goal-command.zh.md create mode 100644 examples/acp-agent/tests/snapshots/goal-command-status/input.json create mode 100644 examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl create mode 100644 packages/goal/command-goal/README.md create mode 100644 packages/goal/command-goal/package.json create mode 100644 packages/goal/command-goal/src/index.ts create mode 100644 packages/goal/command-goal/tests/command-goal.spec.ts create mode 100644 packages/goal/command-goal/tsconfig.json diff --git a/docs/architecture.md b/docs/architecture.md index b83c7494bb..f32a7dbb27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,7 +157,7 @@ Exceptions combine layers: LLM interface/consumer; filesystem policy; web regist ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). Apps add front doors; TUI/ACP mount commands ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`, including the Python SDK default ([Python SDK](../python/README.md)). Deployments stay thin with swappable backends/tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine and an opt-in persisted-goal stack ([README](../packages/examples/agent-spine-demo/README.md)). Terminal and ACP apps enable goals plus the shared `/goal` command by default; other apps choose explicitly. `dsh-jsonrpc-agent` boots external `cordis.yml`, including the Python SDK default ([Python SDK](../python/README.md)). Deployments stay thin with swappable backends/tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 15433ee095..0cbe1854e1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -66,12 +66,14 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false } ``` Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:34`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:35`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -115,7 +117,8 @@ Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `goals` opts into and configures the persisted goal + * domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -142,6 +145,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ + goals?: GoalConfig | false } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -155,11 +160,19 @@ export interface SkillConfig { /** Model-facing skill catalog and tool settings. */ tool?: toolSkill.Config } + +/** Persisted goal domain, model-tool policy, and same-session driver config. */ +export interface GoalConfig { + /** Goal-domain creation defaults. */ + domain?: GoalDomainConfig + /** Model-facing goal-tool authority policy. */ + tool?: toolGoal.Config +} ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:71`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -878,6 +891,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` @@ -902,7 +917,7 @@ export type TerminalMode = 'auto' | 'readline' | 'tui' Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:76`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:77`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1491,6 +1506,7 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts)) - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 313cb8489b..61340e9364 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: c6bf6ddd4bf0da8bd7377ab57e779750b72bd25e -extension-cookbook.zh.md: dcc188b7d3ac44f5d86253c20147e95da0bea648 +extension-cookbook.md: 145d7fdcf779d353ddaef3070fa1fd307305c9cf +extension-cookbook.zh.md: 5e0fd8fc5fcb1d1fc2729d03cb1babd94d1bb9ab diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index c6bf6ddd4b..145d7fdcf7 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -98,7 +98,7 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | -| `/goal` | force-continue via `agent/turn-continuation` + `steer()` reminders | +| `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index dcc188b7d3..5e0fd8fc5f 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -98,7 +98,7 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | -| `/goal` | 通过 `agent/turn-continuation` 强制继续 + `steer()` 提醒 | +| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b89e9fa891..5de52ce8c3 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -439,7 +439,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ diff --git a/docs/glossary.md b/docs/glossary.md index 7759f45542..9a27ead799 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,13 +20,14 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. -- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. +- **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later human-authorized resume mutation through `/goal` or the model tool before automatic work. ## human command - **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. - **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. - **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent. +- **goal command** — the `/goal` human command contributed by `dsh-command-goal`; it observes or mutates the current goal directly while the goal domain owns every durable, model-visible record. ## loop hierarchy diff --git a/docs/module-graph.md b/docs/module-graph.md index 7222397cd9..a9b4876334 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -29,6 +29,7 @@ flowchart TD pkg_tools["tools"] end subgraph group_goal["packages/goal"] + pkg_command_goal["command-goal"] pkg_goal["goal"] pkg_goal_session["goal-session"] pkg_tool_goal["tool-goal"] @@ -254,6 +255,8 @@ flowchart TD pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval + pkg_command_goal --> pkg_commands + pkg_command_goal --> pkg_goal pkg_goal_session --> pkg_agent pkg_goal_session --> pkg_goal pkg_goal_session --> pkg_llm @@ -415,6 +418,8 @@ flowchart TD pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm @@ -424,6 +429,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_system_prompt pkg_agent_spine_demo --> pkg_tasks pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools @@ -444,6 +450,7 @@ flowchart TD pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_command_goal pkg_acp_demo --> pkg_commands pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools @@ -461,6 +468,7 @@ flowchart TD pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot + pkg_stdio_demo --> pkg_command_goal pkg_stdio_demo --> pkg_commands pkg_stdio_demo --> pkg_llm pkg_stdio_demo --> pkg_session @@ -531,6 +539,7 @@ flowchart TD | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | @@ -562,10 +571,10 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index f7019bf60e..90e508da27 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -90,6 +90,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Human `/goal` command](implemented/feature/2026-07-19-human-goal-command.md) | 2026-07-19 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | | [Plugin-owned human command registration](implemented/feature/2026-07-19-plugin-command-registration.md) | 2026-07-19 | diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 1356afa06b..a69caa05ba 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching separately sourced `additionalContexts[]`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer a context envelope or durable context metadata. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn. It is not a `context/message`, so its type does not offer a context envelope or durable context metadata. ### The tool pipeline gives each phase one kind of authority diff --git a/docs/rfc/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.i18n.yaml new file mode 100644 index 0000000000..fb49026f5b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-human-goal-command.md: 5392165732b50d1422960755efea7d8f1d31aea0 +2026-07-19-human-goal-command.zh.md: ca1882fdd347dc91e504681c8f42560c83a3b589 diff --git a/docs/rfc/implemented/feature/2026-07-19-human-goal-command.md b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.md new file mode 100644 index 0000000000..5392165732 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.md @@ -0,0 +1,72 @@ +# RFC: Human `/goal` command + +Status: implemented + +English | [中文](2026-07-19-human-goal-command.zh.md) + +## Problem + +The same-session goal domain and model tools provide the state machine and semantic natural-language path, but they are not a sufficient human UX. A user needs to inspect the exact current phase and round budget without asking the model, explicitly pause or clear work without spending a model turn, and rearm a restored active goal after the required post-resume human decision. Implementing those actions independently in TUI and ACP would duplicate parsing, let the surfaces drift, and risk routing an unknown or unavailable command into the model. + +The command must also respect the goal design's two kinds of state. Durable phase, objective, revisions, and rounds come from the session log; process-local activation decides whether an active goal may continue automatically. Showing only “active” after a resume would be misleading when the restored goal is intentionally disarmed and waiting for human authorization. + +## Decision + +`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition for the TUI and ACP surfaces. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. + +The command follows the compact current Codex shape documented by the [official developer-command reference](https://learn.chatgpt.com/docs/developer-commands?surface=cli): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. + +### Grammar and lifecycle verbs + +`/goal` reports the objective, human-readable durable phase, `roundsStarted/maxGoalRounds`, process-local `armed` or `disarmed` activation, and commands meaningful from that state. With no current goal it reports that fact plus complete usage. Reading status adds no session event. + +`/goal ` creates an active armed goal. A completed goal may be replaced, which creates a fresh goal identity through the existing domain rule. Any unfinished goal makes the command fail directly with instructions to use inline edit or explicit clear. The generic command service deliberately has no modal confirmation API, so silently clearing and creating two durable records would manufacture destructive consent and expose a non-atomic failure window. + +`/goal edit ` edits the current non-complete goal without changing phase or activation. On a completed goal it creates a fresh active goal because the domain does not permit completed state to resume and a new completion objective is a new goal identity. Bare `edit` is an error rather than an editor launch because ACP's shared unstructured command contract has no portable modal editor. + +`/goal pause`, `/goal resume`, and `/goal clear` call the matching compare-and-set domain verbs against the current view. Resume covers both stopped durable phases and an active-but-disarmed goal after session resume, fork, or driver replacement. Domain rules still reject exhausted round caps, redundant active/armed resume, invalid phase transitions, and stale identity. Clear removes the current pointer while the session log retains the revisioned tombstone and earlier snapshots. + +Control words are ASCII-case-insensitive after outer whitespace trimming. They are controls only when they occupy the full suffix; any other non-empty text is an objective. This matches the predictable free-form command rule: `/goal pause after verification` is a goal objective, not a partially parsed pause command. + +### Output and failure boundary + +Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or resumable stopped goal offers resume, budget-limited and completed states do not advertise an invalid resume. + +Expected `GoalError` failures become `CommandResult.error`, so invalid human operations receive a stable direct response and never enter model history. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. + +Generic slash input, status text, and errors are not persisted. Successful goal mutations use the existing `Agent.inject()` path, producing the raw model-visible goal snapshot or clear tombstone that persistence already owns. The command therefore changes no session format and introduces no second audit record that could disagree with the domain event. + +### App composition + +`agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation. + +The terminal and ACP app bundles make the opposite product choice. They default `goals` to the owner defaults, mount the goal domain, model tools, same-session driver, command registry, and this producer, and accept `goals: false` as one coherent opt-out. The TUI and ACP bridge then discover the same definition through the generic registry. The line-oriented stdio mode does not consume the command plane; a typed `/goal` there remains an ordinary human prompt that the model may interpret through its separately authorized goal tools. + +## Testing + +The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, discovery on both surfaces, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, armed/disarmed presentation, round-budget presentation, expected domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, terminal/ACP defaults, coherent opt-out, forwarded domain/tool config, command discovery, and the expanded model-tool assembly. The keyless ACP snapshots pin the resulting `/goal` discovery metadata and goal tool schemas in the shipped app composition. + +## Alternatives considered + +- **Let the model handle `/goal` as ordinary text** — rejected because status and direct lifecycle actions would cost a model turn, could be reinterpreted, and would not provide deterministic ACP discovery. +- **Implement separate TUI and ACP handlers** — rejected because grammar, error behavior, and goal-state formatting would drift and optional deployments could not add or remove the capability as one effect. +- **Add modal editing and replacement confirmation to `ctx.commands`** — rejected because the existing cross-surface contract is unstructured input plus direct output; a general interaction protocol needs more than this one producer. +- **Silently replace an unfinished goal** — rejected because it combines clear and create without atomicity or explicit destructive intent. +- **Expose goal id and revision in human status** — rejected because human actions always target the exact current view inside one synchronous handler; those fields add implementation noise without preventing another race. +- **Enable goals unconditionally in the UI-less spine** — rejected because one-shot SDK/CLI settlement is a physical-turn API, not a goal-operation API. + +## Consequences + +- TUI and ACP expose one Codex-shaped `/goal` command supplied by a removable plugin. +- Human status distinguishes durable phase from live activation and reports the exact goal-round cap. +- Direct pause, resume, clear, creation, and edit consume no model turn while their accepted mutations remain reconstructable from the session log. +- Restored sessions wait for a human decision; `/goal resume` is the literal command path, while an ordinary prompt in any language may authorize the model tool path. +- Headless compositions retain one-turn behavior unless they explicitly opt into goals and define their own long-running settlement contract. + +## Known limitations and deferred work + +- The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. +- `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. +- TUI and ACP render portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. +- The line-oriented stdio and JSON-RPC front doors do not consume the command registry. +- The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/docs/rfc/implemented/feature/2026-07-19-human-goal-command.zh.md b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.zh.md new file mode 100644 index 0000000000..ca1882fdd3 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -0,0 +1,72 @@ +# RFC:面向人类的 `/goal` 命令 + +Status: implemented + +[English](2026-07-19-human-goal-command.md) | 中文 + +## 问题 + +同会话目标领域和模型工具提供了状态机与自然语言语义路径,但尚不足以构成面向人类的 UX。用户需要在不询问模型的情况下检查准确的当前阶段与回合预算,在不消耗模型轮次的情况下明确暂停或清除工作,并在会话恢复后经过必要的人类决策重新激活已恢复的活跃目标。若在 TUI 与 ACP 中分别实现这些操作,就会重复解析逻辑、导致两个表面发生偏差,还可能把未知或不可用的命令交给模型处理。 + +该命令还必须遵守目标设计中的两类状态。持久阶段、目标描述、修订号与回合来自会话日志;进程本地激活态决定活跃目标能否自动继续。恢复后若只显示“活跃”,就会掩盖目标已被有意设为未激活、正在等待人类授权这一事实。 + +## 决策 + +位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它为 TUI 和 ACP 表面注册一个全局 `goal` 定义。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 + +该命令遵循[官方开发者命令参考](https://learn.chatgpt.com/docs/developer-commands?surface=cli)所记录的当前 Codex 紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 + +### 语法与生命周期动词 + +`/goal` 报告目标描述、面向人类的持久阶段、`roundsStarted/maxGoalRounds`、进程本地 `armed` 或 `disarmed` 激活态,以及当前状态下有意义的命令。没有当前目标时,它会报告该事实与完整用法。读取状态不会添加会话事件。 + +`/goal ` 创建活跃且已激活的目标。已完成目标可以被替换,此时通过现有领域规则创建新的目标身份。任何未完成目标都会让命令直接失败,并提示用户使用行内编辑或明确清除。通用命令服务有意不提供模态确认 API;若静默执行清除再创建两条持久记录,就等于凭空制造破坏性同意,并暴露一个非原子的失败窗口。 + +`/goal edit ` 编辑当前未完成目标,但不改变其阶段或激活态。若目标已经完成,则创建一个新的活跃目标,因为领域不允许恢复已完成状态,而新的完成条件应拥有新的目标身份。单独使用 `edit` 会返回错误而不是启动编辑器,因为 ACP 共享的非结构化命令契约没有可移植的模态编辑器。 + +`/goal pause`、`/goal resume` 与 `/goal clear` 使用当前视图调用相应的比较并交换领域动词。恢复既适用于停止的持久阶段,也适用于会话恢复、fork 或驱动器替换后处于活跃但未激活状态的目标。领域规则仍会拒绝已耗尽的回合上限、对已活跃且已激活目标的重复恢复、非法阶段转换与陈旧身份。清除会移除当前指针,而会话日志保留带修订号的墓碑和此前快照。 + +控制词会在去除两端空白后按 ASCII 大小写不敏感方式匹配。只有占据完整后缀时才被视为控制;其余任何非空文本都是目标描述。这保持了可预测的自由形式命令规则:`/goal pause after verification` 是目标描述,而不是被部分解析的暂停命令。 + +### 输出与失败边界 + +状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或可恢复的停止目标提供恢复,受预算限制和已完成状态不会宣传非法的恢复操作。 + +预期的 `GoalError` 失败会变为 `CommandResult.error`,因此非法人类操作会收到稳定的直接响应,且绝不会进入模型历史。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 + +通用斜杠输入、状态文本与错误不会持久化。成功的目标变更使用现有 `Agent.inject()` 路径,产出持久化本就拥有的原始模型可见目标快照或清除墓碑。因此该命令不会改变会话格式,也不会引入可能与领域事件不一致的第二份审计记录。 + +### 应用组合 + +`agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在一个相关物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 + +终端和 ACP 应用包作出相反的产品选择。它们默认让 `goals` 使用所有者默认值,挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方,并接受 `goals: false` 作为一致的整体退出选项。随后,TUI 与 ACP 桥通过通用注册表发现同一个定义。行式 stdio 模式不消费命令平面;在那里输入的 `/goal` 仍是普通人类提示词,模型可以通过独立授权的目标工具解释它。 + +## 测试 + +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、两个表面的发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、已激活/未激活展示、回合预算展示、预期领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、终端/ACP 默认值、一致退出、转发的领域/工具配置、命令发现与扩展后的模型工具组装。无密钥 ACP 快照固定了交付应用组合中的 `/goal` 发现元数据和目标工具 schema。 + +## 考虑过的替代方案 + +- **让模型把 `/goal` 当作普通文本处理**——不予采纳,因为状态与直接生命周期操作会消耗模型轮次、可能被重新解释,也无法提供确定性的 ACP 发现。 +- **分别实现 TUI 和 ACP 处理器**——不予采纳,因为语法、错误行为与目标状态格式会发生偏差,可选部署也无法把该功能作为一个 effect 统一增删。 +- **为 `ctx.commands` 添加模态编辑与替换确认**——不予采纳,因为现有跨表面契约是非结构化输入加直接输出;通用交互协议所需的设计远超这一个生产方。 +- **静默替换未完成目标**——不予采纳,因为这会在没有原子性或明确破坏性意图的情况下组合清除与创建。 +- **在人类状态中暴露目标 id 与修订号**——不予采纳,因为人类操作始终在一个同步处理器内针对准确当前视图;这些字段只会增加实现噪声,无法消除其他竞争。 +- **在无 UI 主干中无条件启用目标**——不予采纳,因为单次 SDK/CLI 的结束契约是物理轮次 API,而不是目标操作 API。 + +## 后果 + +- TUI 与 ACP 暴露由可移除插件提供的同一个 Codex 形态 `/goal` 命令。 +- 人类状态会区分持久阶段与实时激活态,并报告准确的目标回合上限。 +- 直接暂停、恢复、清除、创建与编辑不消耗模型轮次,而其已接受变更仍可从会话日志重建。 +- 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。 +- 无头组合保持单轮行为,除非明确选择加入目标并定义自己的长时间运行结束契约。 + +## 已知限制与延期工作 + +- 可移植命令契约没有模态编辑器或确认交互;在出现通用跨表面交互原语之前,行内编辑与明确清除是有意选择。 +- `/goal` 不接受逐命令回合上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 +- TUI 与 ACP 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 +- 行式 stdio 与 JSON-RPC 前端不消费命令注册表。 +- 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离契约的独立策略层。 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index e4e0fbf855..b2b1983e31 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: cb823aa944d69228005884ac73cc99b67fa00dfb -2026-07-19-model-facing-goal-tools.zh.md: f93ffa3a2eed602d8c7d98faccf09c9e46924f28 +2026-07-19-model-facing-goal-tools.md: 7b4e9aa69215ae223ec043ea9e7612757598f024 +2026-07-19-model-facing-goal-tools.zh.md: c2da32aefd41ffc0c5cf25e1db89fbe38e74b878 diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md index cb823aa944..7b4e9aa692 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -61,5 +61,5 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. - These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. -- Human slash-command discovery and rendering are deferred to the command-surface layer. +- Human slash-command discovery and rendering are owned by the separate [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) plugin. - A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index f93ffa3a2e..c2da32aefd 100644 --- a/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/docs/rfc/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -61,5 +61,5 @@ Status: implemented - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 - 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 -- 面向人类的斜杠命令发现与渲染延期到命令表面层。 +- 面向人类的斜杠命令发现与渲染由独立的 [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) 插件负责。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 37b420378c..c894d6f44f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -49,6 +49,8 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['m const SCENARIOS: Scenario[] = [ { name: 'handshake', hasModelTurn: false, recorded: false }, { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, + // Direct command dispatch reports goal state without spending a model turn. + { name: 'goal-command-status', hasModelTurn: false, recorded: false }, // text-turn is the pinned-header scenario: the minimal single text turn. // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index 50d688089e..beec785c9d 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index b8acef973c..5ba28dca73 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -61,6 +63,15 @@ declare const tools: { /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ id: string; }): Promise; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer cap; omission uses the goal-domain deployment default. */ + max_goal_rounds?: number; + }): Promise; + /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ + get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -112,6 +123,19 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 978819fa1f..c27f1788e7 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -102,6 +102,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", @@ -274,6 +302,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index 875ff05303..30ed2e1e42 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 6c6e1d6158..3e626e9b08 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index f1a3b9ff92..9b14172e43 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -44,6 +46,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer cap; omission uses the goal-domain deployment default. */ + max_goal_rounds?: number; + }): Promise; + /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ + get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -95,6 +106,19 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index edf1a7c001..5112f55a25 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -45,6 +45,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", @@ -217,6 +245,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 1e1131398b..9bce7d7abc 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index 0277ef2f90..9a5b62c9ff 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 2e06d6839a..99392cc7bd 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index f1a3b9ff92..9b14172e43 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -44,6 +46,15 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer cap; omission uses the goal-domain deployment default. */ + max_goal_rounds?: number; + }): Promise; + /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ + get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ @@ -95,6 +106,19 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index a41a9b8b3b..ce8ff9559c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index cc8e2d1301..ec0b4430e1 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -50,6 +52,13 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal(args: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer cap; omission uses the goal-domain deployment default. */ + max_goal_rounds?: number; + }): Promise; /** Edit an existing UTF-8 text file by replacing literal text. */ edit(args: { /** Path to edit, resolved by the filesystem backend. */ @@ -61,6 +70,8 @@ declare const tools: { /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ replace_all?: boolean; }): Promise; + /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ + get_goal(args: Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ @@ -121,6 +132,19 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + update_goal(args: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl index 4e2f95accf..bea7a201ab 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 50c05a4d18..36e8f5a465 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 80ab62b674..ffbd04b32e 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index e169016a1c..4373d324d7 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index 5a60174abb..5e6bedf862 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index 451fe781dd..59550792de 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index 39ddf764de..4775a2a4cb 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 2f2e1bc667..f8986d5e02 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 3fed936cfc..b553d503b5 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl index 51bb5f7c65..c24f5b4383 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 9e0d057b2a..7517f30768 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 927bb99975..8d0043ab6b 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/input.json b/examples/acp-agent/tests/snapshots/goal-command-status/input.json new file mode 100644 index 0000000000..0bc0192c93 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "/goal" } + ] +} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl b/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl new file mode 100644 index 0000000000..a6f73319bc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} diff --git a/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl new file mode 100644 index 0000000000..568b5a8f46 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index 1390e424d6..54ed9b292e 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index 89e476b3e8..9d3e34dd57 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index b13a498bc0..d50c69954e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 5acbeb7202..8addaad274 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index 623127e083..d28e38a28e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..dfb95675e7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 3f75a15f57..12cc700248 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index bf478888b4..87d4aa1b16 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index b8b6abe2c3..2ce2a388b8 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index c8c34ab6ec..f59c72c0b4 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 8e303806a9..4a75fac864 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..dfb95675e7 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index 6107da23da..72bce49963 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index ef02915d01..c308028ff2 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl index e3a5aacbeb..e236bfee60 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index b9701e538c..f81876701a 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -27,6 +29,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 7c814257fb..bae7be5f6b 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -45,6 +45,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -201,6 +229,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", @@ -320,6 +389,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -476,6 +573,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 57dd33f320..d3a40dad97 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 53048fa11d..0961149943 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl index 2232cd3a1c..ea46e09493 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 622bc4e23a..82335a7f1e 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. @@ -26,6 +28,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 7c814257fb..bae7be5f6b 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -45,6 +45,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -201,6 +229,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", @@ -320,6 +389,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -476,6 +573,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl index bc40beb3fe..d1b75d0d98 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index 8436971982..1259c06086 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 43ecb9746f..87818d5b6d 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index e422a063da..0680b50e05 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -45,6 +45,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -201,6 +229,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index a332ea7a7c..15ae069004 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index ded1ec01cc..d4007eb8be 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index d25e78d0db..eca8dc0fc8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index d1f79bacac..9ab55a7389 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index dc7b4dbe25..20c758bc59 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 43ecb9746f..87818d5b6d 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -9,6 +9,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index e422a063da..0680b50e05 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -45,6 +45,34 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -201,6 +229,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl index 0cae765592..0a32a02746 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index dce9237834..8f4bc536fa 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index 73604add34..37626e6b7d 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl index 8108f47bb0..707d7a1c48 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index b1fc71924b..6f49fcf1c2 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 4b08a1e365..0ef5307126 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -75,6 +95,14 @@ ] } }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -255,6 +283,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index ff2610f1f5..81087ddd50 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md index ddf502a773..4af39f6b4b 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. + Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json index 4b08a1e365..0ef5307126 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json @@ -45,6 +45,26 @@ ] } }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "edit", "description": "Edit an existing UTF-8 text file by replacing literal text.", @@ -75,6 +95,14 @@ ] } }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", @@ -255,6 +283,47 @@ ] } }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, { "name": "workflow", "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 80d085c32b..0ab78067e9 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -523,7 +523,7 @@ describe('steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing') }) - it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => { + it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => { // Assert the same-turn shape; content alone cannot distinguish re-enqueue. const adapter = new MockAdapter([ textResponse('no tools, would stop'), diff --git a/packages/examples/README.md b/packages/examples/README.md index 58cffeabac..32e287456d 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -4,10 +4,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + command registry + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + persisted goals + `/goal` command + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + command registry + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | `agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index d215cbcbbe..80593e38ec 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -12,6 +12,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) | | `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch | +| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | @@ -36,6 +37,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. @@ -54,7 +56,7 @@ All diagnostics go to **stderr** — stdout is the protocol. ## Model Experience -Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. +Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots. #### KV Cache effect diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index f74617f306..1343e9f713 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 4000954c72..e44135d8d1 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -13,6 +13,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' import CommandService from '@deepseek-ai/dsh-commands' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -56,6 +57,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false } // Each front door owns a complete, directly readable config schema; extracting @@ -77,6 +80,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), }) /* jscpd:ignore-end */ @@ -88,8 +92,10 @@ export const Config: z = z.object({ * from the provider/model pair. No logger, no `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { + const goals = config.goals ?? {} ctx.plugin(CommandService) - ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) + if (goals !== false) ctx.plugin(commandGoal) + ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(acp, { provider: config.provider, model: config.model }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 10ee749f8c..a9a1d6178d 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -77,11 +77,30 @@ describe('dsh-acp-demo composition', () => { expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() + expect(ctx.get('goals')).toBeDefined() + expect(ctx.get('tools')?.get('get_goal')).toBeDefined() // No pre-created agents — ACP session/new creates them on demand. expect(ctx.get('agents')!.list()).toHaveLength(0) await ctx.fiber.dispose() }) + it('can explicitly omit the persisted-goal stack and its command', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + goals: false, + workspaceContext: false, + }) + expect(ctx.get('goals')).toBeUndefined() + const handle = await ctx.agents.create({ + sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(ctx.commands.find(handle.agent, 'acp', 'goal')).toBeUndefined() + await handle.dispose() + await ctx.fiber.dispose() + }) + it('defaults the persistence root when omitted', async () => { // Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that // bypasses the schema's `.default(...)`: call `apply` directly (not via @@ -179,7 +198,17 @@ describe('dsh-acp-demo composition', () => { }) } const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill', 'task_kill', 'task_list', 'task_output']) + expect(assembly.tools.map(tool => tool.name)).toEqual([ + 'zulu', + 'alpha', + 'create_goal', + 'get_goal', + 'skill', + 'task_kill', + 'task_list', + 'task_output', + 'update_goal', + ]) await ctx.fiber.dispose() }) diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index 78090f6273..d3fc190640 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../goal/command-goal" + }, { "path": "../../core/agent" }, diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 76efd77845..89573a2fc9 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -17,6 +17,9 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events +@deepseek-ai/dsh-goal optional persisted same-session goal domain +@deepseek-ai/dsh-tool-goal optional model-facing goal controls +@deepseek-ai/dsh-goal-session optional same-session goal-round driver @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash schema @@ -42,11 +45,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include @@ -54,7 +57,7 @@ A YAML include can deduplicate config but cannot own a bin or provide front-door ## Model Experience -Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. +Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own. #### KV Cache effect @@ -62,5 +65,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 772d6c059b..f1efc86de4 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin with optional persisted goals", "version": "0.0.1", "private": true, "type": "module", @@ -25,6 +25,8 @@ "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-goal-session": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -35,6 +37,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tool-bash": "^0.0.1", + "@deepseek-ai/dsh-tool-goal": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tool-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -44,6 +47,8 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", @@ -55,6 +60,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 74ba5e0cc9..b7d7e96507 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -1,6 +1,6 @@ /** * Default executor-less, UI-less agent spine. It bundles the common services, - * background-task registry and controls, concrete loop, local skill and + * background-task registry and controls, optional persisted goals, concrete loop, local skill and * workspace-context providers, and model-facing bash/skill consumers; * deployments still choose the LLM adapter, bash executor, and presentation. * The plugin intentionally exposes named exports only because Loader default @@ -18,6 +18,9 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' +import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' +import * as goalSession from '@deepseek-ai/dsh-goal-session' +import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import TaskService from '@deepseek-ai/dsh-tasks' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -41,6 +44,14 @@ export interface SkillConfig { tool?: toolSkill.Config } +/** Persisted goal domain, model-tool policy, and same-session driver config. */ +export interface GoalConfig { + /** Goal-domain creation defaults. */ + domain?: GoalDomainConfig + /** Model-facing goal-tool authority policy. */ + tool?: toolGoal.Config +} + /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP @@ -50,7 +61,8 @@ export interface SkillConfig { * `dshHome` to bash environment and local skill discovery, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Owner schemas supply defaults for optional input; + * plugins this bundle owns. `goals` opts into and configures the persisted goal + * domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input; * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their @@ -77,6 +89,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ + goals?: GoalConfig | false } /** The skill config schema exported for app packages that forward `skills`. */ @@ -93,6 +107,12 @@ export const ToolBashConfigSchema: z = toolBash.Config /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config +/** The persisted-goal config schema exported for app packages that opt in. */ +export const GoalConfigSchema: z = z.object({ + domain: GoalService.Config, + tool: toolGoal.Config, +}) + /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -104,7 +124,8 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), - }) as unknown as z>, + goals: z.union([z.const(false), GoalConfigSchema]), + }) as unknown as z>, ]) as unknown as z /** @@ -123,6 +144,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('goals')).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => { + const ctx = await mount({ + workspaceContext: false, + goals: { + domain: { defaultMaxGoalRounds: 17 }, + tool: { blockedAfterConsecutiveRounds: 5 }, + }, + }) + expect(ctx.goals.resolveCreate({ objective: 'configured' })).toEqual({ + objective: 'configured', + maxGoalRounds: 17, + }) + expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) + .toEqual(['create_goal', 'get_goal', 'update_goal']) + expect((await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:goal')?.text) + .toContain('at least 5 consecutive goal rounds') + await ctx.fiber.dispose() + }) + + it('accepts an explicit false goal composition without mounting it', async () => { + const ctx = await mount({ workspaceContext: false, goals: false }) + expect(ctx.get('goals')).toBeUndefined() + expect(ctx.tools.get('get_goal')).toBeUndefined() await ctx.fiber.dispose() }) @@ -170,6 +197,18 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => { + const ctx = new Context() + agentCore.apply(ctx, { workspaceContext: false, goals: {} }) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(ctx.goals.resolveCreate({ objective: 'defaulted' })).toEqual({ + objective: 'defaulted', + maxGoalRounds: 256, + }) + expect(ctx.tools.get('get_goal')).toBeDefined() + await ctx.fiber.dispose() + }) + it('loads workspace instructions into requests through the bundled spine', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-workspace-context-')) try { diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 89cb2accd8..ba9d69e841 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -41,6 +41,15 @@ { "path": "../../core/agent" }, + { + "path": "../../goal/goal" + }, + { + "path": "../../goal/tool-goal" + }, + { + "path": "../../goal/goal-session" + }, { "path": "../../context/workspace-context" }, diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index f145ccaf16..313e5f47ed 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -12,6 +12,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-commands` | the human-command registry consumed by the TUI front door and optional command plugins | +| `@deepseek-ai/dsh-command-goal` | the direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | @@ -37,6 +38,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | terminal banner / TUI subtitle | | `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | @@ -82,7 +84,7 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — #### What the model sees -Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands and their results remain outside model context. +Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, visible tools, and the enabled goal policy/tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands and their direct results remain outside model context, while accepted `/goal` mutations append the goal domain's model-visible snapshot. #### Token effect @@ -109,5 +111,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. +- **Direct commands require TUI mode** — the line-oriented fallback does not consume `ctx.commands`; an ordinary `/goal` prompt there may instead be interpreted through the model-facing goal tools. - **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. - **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index b8285dc351..17fbe8396d 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index c8fa7b5fd0..307a0db483 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -17,6 +17,7 @@ import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import CommandService from '@deepseek-ai/dsh-commands' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -100,6 +101,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false /** * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` @@ -127,6 +130,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) @@ -145,8 +149,10 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) const mode = resolveTerminalMode(config.ui, isTTY) + const goals = config.goals ?? {} if (mode === 'readline') ctx.plugin(ConsoleExporter) ctx.plugin(CommandService) + if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(UserInteractionService) if (mode === 'tui') { @@ -163,6 +169,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) } ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), + goals, agents: [{ id: SessionId('main'), provider: config.provider, diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index c8ca063151..f20860f1eb 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -90,6 +90,7 @@ describe('dsh-stdio-demo app', () => { ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, }, true) expect(calls.map(call => call.name)).toContain('ui-tui') + expect(calls.map(call => call.name)).toContain('command-goal') expect(calls.map(call => call.name)).not.toContain('ui-stdio') expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string } @@ -99,6 +100,7 @@ describe('dsh-stdio-demo app', () => { agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }> } expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId }) + expect(spineConfig).toMatchObject({ goals: {} }) calls.length = 0 stdioAgent.composeTerminalApp(ctx, { @@ -116,11 +118,13 @@ describe('dsh-stdio-demo app', () => { calls.length = 0 stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' }, + provider: 'mock', model: 'mock', workspaceContext: false, goals: false, ui: { mode: 'readline' }, }, false) expect(calls.map(call => call.name)).toContain('ui-stdio') expect(calls.map(call => call.name)).toContain('ConsoleExporter') expect(calls.map(call => call.name)).not.toContain('ui-tui') + expect(calls.map(call => call.name)).not.toContain('command-goal') + expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: false }) }) it('composes the spine + front-door cluster and pre-creates the main agent', async () => { @@ -131,6 +135,8 @@ describe('dsh-stdio-demo app', () => { expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() + expect(ctx.get('goals')).toBeDefined() + expect(ctx.get('tools')?.get('get_goal')).toBeDefined() // The sole pre-created agent the UI drives. `main` is its stable config // label; each fresh process mints a durable combined agent/session id. await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) @@ -139,6 +145,7 @@ describe('dsh-stdio-demo app', () => { expect(agent?.id).toBe(agent?.session.id) expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) + expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeDefined() await ctx.fiber.dispose() }) @@ -273,7 +280,18 @@ describe('dsh-stdio-demo app', () => { }) } const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output']) + expect(assembly.tools.map(tool => tool.name)).toEqual([ + 'zulu', + 'alpha', + 'ask_user_question', + 'create_goal', + 'get_goal', + 'skill', + 'task_kill', + 'task_list', + 'task_output', + 'update_goal', + ]) await ctx.fiber.dispose() }) diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index 61646af70f..16d1108e20 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../goal/command-goal" + }, { "path": "../agent-spine-demo" }, diff --git a/packages/goal/README.md b/packages/goal/README.md index fd0b94f321..95cd975b69 100644 --- a/packages/goal/README.md +++ b/packages/goal/README.md @@ -7,5 +7,6 @@ The goal family owns durable objective state independently of the model-facing t | `goal/` | Event-sourced goal lifecycle, replay fold, compare-and-set mutations, and process-local activation | `ctx.goals` | | `goal-session/` | Same-session goal-round admission, outcome mapping, and lifecycle race fencing | — | | `tool-goal/` | Model-facing read/create/update tools with execution-time authority checks | — | +| `command-goal/` | Human-facing `/goal` status and lifecycle control over the command plane | — | Goal state is part of the owning session log. Consumers depend on `dsh-goal`, not on the concrete agent loop; continuation behavior belongs in a separate plugin on the public agent seams. diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md new file mode 100644 index 0000000000..d592b51637 --- /dev/null +++ b/packages/goal/command-goal/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-command-goal + +Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md); TUI and ACP discover and execute it without a model turn. The [human goal-command RFC](../../../docs/rfc/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. + +## Command contract + +| Input | Result | +|---|---| +| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; show usage when no goal exists. | +| `/goal ` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. | +| `/goal edit ` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. | +| `/goal pause` | Pause an active goal and disarm continuation. | +| `/goal resume` | Resume a stopped goal or rearm an active goal after session resume/fork, subject to its remaining round cap. | +| `/goal clear` | Clear the current pointer while retaining its durable history and tombstone. | + +Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. + +Expected domain rejections become direct command errors. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin. + +## Composition + +The producer injects `commands` and `goals`. A custom app mounts their owners plus this plugin; automatic continuation remains an independent choice: + +```yaml +- id: commands + name: '@deepseek-ai/dsh-commands' +- id: goal + name: '@deepseek-ai/dsh-goal' +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' +``` + +The terminal and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. + +## Model Experience + +### Human `/goal` control + +#### What the model sees + +The slash input and direct status/error output are absent from model requests. An accepted mutation later appears through the goal domain's raw `` snapshot or clear tombstone; this preserves the model-visible-is-logged invariant without logging presentation text. + +#### Token effect + +Reading status or receiving a direct command error adds no model tokens. Each accepted mutation adds the goal domain's retained full snapshot, and an enabled same-session driver may add later goal-round prompts. + +#### KV Cache effect + +Command discovery and direct output do not affect the cache. A mutation appends after the reusable history prefix; later compaction may replace the derived-history suffix. + +## Known Limitations and Deferred Work + +- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP. +- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool. +- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work. +- **TUI and ACP only** — the line-oriented stdio and JSON-RPC adapters do not consume `ctx.commands`. Their ordinary human prompts can still authorize the model-facing goal tools when those are composed. diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json new file mode 100644 index 0000000000..87da2ffaea --- /dev/null +++ b/packages/goal/command-goal/package.json @@ -0,0 +1,38 @@ +{ + "name": "@deepseek-ai/dsh-command-goal", + "description": "Human-facing slash command for persisted same-session goals", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts new file mode 100644 index 0000000000..3fff035adc --- /dev/null +++ b/packages/goal/command-goal/src/index.ts @@ -0,0 +1,165 @@ +/** + * Human-facing `/goal` command over the persisted same-session goal domain. + * @module @deepseek-ai/dsh-command-goal + */ + +import type { Context } from 'cordis' +import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import { GoalError } from '@deepseek-ai/dsh-goal' +import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' + +export const name = 'command-goal' +export const inject = ['commands', 'goals'] + +const USAGE = 'Usage: /goal [|clear|edit |pause|resume]' + +type GoalCommand = + | { readonly kind: 'show' } + | { readonly kind: 'create'; readonly objective: string } + | { readonly kind: 'edit'; readonly objective: string } + | { readonly kind: 'invalid-edit' } + | { readonly kind: 'pause' } + | { readonly kind: 'resume' } + | { readonly kind: 'clear' } + +/** Fail loudly if a locally closed union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop is unreachable without violating the TypeScript contract */ +function assertNever(value: never, label: string): never { + throw new TypeError(`unknown ${label}: ${String(value)}`) +} +/* v8 ignore stop */ + +/** Parse only the grammar owned by `/goal`; arbitrary other input is an objective. */ +function parseGoalCommand(rawInput: string): GoalCommand { + const input = rawInput.trim() + if (input.length === 0) return { kind: 'show' } + const control = input.toLowerCase() + if (control === 'clear') return { kind: 'clear' } + if (control === 'pause') return { kind: 'pause' } + if (control === 'resume') return { kind: 'resume' } + if (control === 'edit') return { kind: 'invalid-edit' } + if (/^edit(?=\s)/iu.test(input)) return { kind: 'edit', objective: input.slice(4).trim() } + return { kind: 'create', objective: input } +} + +/** Human label for one durable goal phase. */ +function phaseLabel(phase: GoalPhase): string { + switch (phase) { + case 'active': return 'active' + case 'paused': return 'paused' + case 'blocked': return 'blocked' + case 'usage-limited': return 'usage limited' + case 'budget-limited': return 'limited by round budget' + case 'complete': return 'complete' + /* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */ + default: return assertNever(phase, 'goal phase') + } +} + +/** Commands that are meaningful from one exact live state. */ +function commandHint(goal: GoalView): string { + if (goal.phase === 'active') { + return goal.activation === 'armed' + ? '/goal edit , /goal pause, /goal clear' + : '/goal edit , /goal resume, /goal clear' + } + switch (goal.phase) { + case 'paused': + case 'blocked': + case 'usage-limited': + return '/goal edit , /goal resume, /goal clear' + case 'budget-limited': + return '/goal edit , /goal clear' + case 'complete': + return '/goal , /goal clear' + /* v8 ignore next 2 -- the active branch and every non-active phase are handled above */ + default: return assertNever(goal.phase, 'goal phase') + } +} + +/** Render direct UI output without exposing compare-and-set internals. */ +function renderGoal(title: string, goal: GoalView): CommandResult { + return { + kind: 'success', + text: [ + title, + `Status: ${phaseLabel(goal.phase)}`, + `Objective: ${goal.objective}`, + `Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`, + `Activation: ${goal.activation}`, + '', + `Commands: ${commandHint(goal)}`, + ].join('\n'), + } +} + +/** Exact current compare-and-set ref. */ +function goalRef(goal: GoalView): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Direct error for an operation that requires a current goal. */ +function missingGoal(action: string): CommandResult { + return { + kind: 'error', + text: `No goal is currently set; /goal ${action} requires one. ${USAGE}`, + } +} + +/** Execute one parsed human command through the domain that owns persistence. */ +function executeGoalCommand(ctx: Context, invocation: CommandInvocation): CommandResult { + const command = parseGoalCommand(invocation.rawInput) + try { + const current = ctx.goals.get(invocation.agent) + switch (command.kind) { + case 'show': + return current === undefined + ? { kind: 'success', text: `No goal is currently set.\n${USAGE}` } + : renderGoal('Goal', current) + case 'invalid-edit': + return { kind: 'error', text: `Goal editing requires a replacement objective.\n${USAGE}` } + case 'create': + if (current !== undefined && current.phase !== 'complete') { + return { + kind: 'error', + text: `A goal is already ${phaseLabel(current.phase)}. Use /goal edit to change it or /goal clear before replacing it.`, + } + } + return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + case 'edit': + if (current === undefined) return missingGoal('edit') + if (current.phase === 'complete') { + return renderGoal('Goal created', ctx.goals.create(invocation.agent, { objective: command.objective })) + } + return renderGoal( + 'Goal updated', + ctx.goals.edit(invocation.agent, goalRef(current), { objective: command.objective }), + ) + case 'pause': + if (current === undefined) return missingGoal('pause') + return renderGoal('Goal paused', ctx.goals.pause(invocation.agent, goalRef(current))) + case 'resume': + if (current === undefined) return missingGoal('resume') + return renderGoal('Goal resumed', ctx.goals.resume(invocation.agent, goalRef(current))) + case 'clear': + if (current === undefined) return { kind: 'success', text: 'No goal to clear.' } + ctx.goals.clear(invocation.agent, goalRef(current)) + return { kind: 'success', text: 'Goal cleared.' } + /* v8 ignore next 2 -- GoalCommand is closed and every member is handled above */ + default: return assertNever(command, 'goal command') + } + } catch (error: unknown) { + if (error instanceof GoalError) return { kind: 'error', text: error.message } + throw error + } +} + +/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */ +export function apply(ctx: Context): void { + ctx.commands.register({ + name: 'goal', + description: 'set or view the goal for a long-running task', + input: { hint: '[|clear|edit |pause|resume]' }, + handler: invocation => executeGoalCommand(ctx, invocation), + }) +} diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts new file mode 100644 index 0000000000..e13f5e9822 --- /dev/null +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, InjectOptions } from '@deepseek-ai/dsh-agent' +import CommandService from '@deepseek-ai/dsh-commands' +import GoalService from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as commandGoal from '@deepseek-ai/dsh-command-goal' + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly session: Session + readonly plugin: Awaited> +} + +/** Number the next balanced injection or message turn. */ +function nextTurn(session: Session): number { + return session.events.reduce( + (maximum, event) => event.type === 'turn/start' ? Math.max(maximum, event.data.turn) : maximum, + 0, + ) + 1 +} + +/** Append one idle injection using the public Agent contract's balanced shape. */ +function appendInjection(session: Session, content: ContentBlock[], options?: InjectOptions): void { + const source: MessageSource = options?.source ?? { kind: 'user' } + const turn = nextTurn(session) + session.append('turn/start', { turn, trigger: { kind: 'injection', source } }) + session.append('context/message', { + content, + source, + ...options?.envelope === undefined ? {} : { envelope: options.envelope }, + ...options?.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +/** Build a live idle agent accepted by the exact-identity goal service. */ +function stubAgent(id: string): { agent: Agent; session: Session } { + const session = new Session(SessionId(id)) + let status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + ctx: new Context(), + get status() { return status }, + send() {}, + steer() {}, + inject(content, options) { appendInjection(session, content, options) }, + cancel() { status = 'idle' }, + whenIdle() { return Promise.resolve() }, + } + return { agent, session } +} + +/** Mount the real command registry, goal domain, and producer. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(CommandService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const plugin = await ctx.plugin(commandGoal) + const { agent, session } = stubAgent(`command-goal-${Math.random()}`) + ctx.agents.register(agent) + return { ctx, agent, session, plugin } +} + +/** Execute `/goal` through the same registry boundary as a UI adapter. */ +async function run(test: Harness, suffix = ''): Promise>>> { + const result = await test.ctx.commands.execute( + test.agent, + 'tui', + `/goal${suffix}`, + new AbortController().signal, + ) + if (result === undefined) throw new Error('goal command was not registered') + return result +} + +/** Current exact compare-and-set ref. */ +function ref(goal: NonNullable>): GoalRef { + return { id: goal.id, revision: goal.revision } +} + +/** Append one admitted goal round for budget-limited presentation coverage. */ +function appendRound(test: Harness, goal: NonNullable>): void { + const source = { kind: 'goal', goalId: goal.id, revision: goal.revision, round: 1 } as const + const turn = nextTurn(test.session) + test.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + test.session.append('user/message', { + content: [{ type: 'text', text: 'goal round' }], + source, + }, { surfaceOp: 'append' }) + test.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +describe('@deepseek-ai/dsh-command-goal registration', () => { + it('registers one global TUI/ACP command with Loader-safe exports and disposes it', async () => { + const test = await harness() + expect(commandGoal.name).toBe('command-goal') + expect(commandGoal.inject).toEqual(['commands', 'goals']) + expect('default' in commandGoal).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(commandGoal)).toBe(commandGoal) + + expect(test.ctx.commands.list(test.agent, 'tui')).toContainEqual({ + name: 'goal', + description: 'set or view the goal for a long-running task', + input: { hint: '[|clear|edit |pause|resume]' }, + surfaces: ['tui', 'acp'], + }) + expect(test.ctx.commands.find(test.agent, 'acp', 'goal')).toBeDefined() + + await test.plugin.dispose() + expect(test.ctx.commands.find(test.agent, 'tui', 'goal')).toBeUndefined() + }) +}) + +describe('/goal human command', () => { + it('shows an empty status without mutating the session', async () => { + const test = await harness() + await expect(run(test)).resolves.toEqual({ + kind: 'success', + text: 'No goal is currently set.\nUsage: /goal [|clear|edit |pause|resume]', + }) + expect(test.session.events).toEqual([]) + }) + + it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => { + const test = await harness() + const created = await run(test, '\n finish the release ') + expect(created.kind).toBe('success') + expect(created.text).toContain('Goal created\nStatus: active') + expect(created.text).toContain('Objective: finish the release') + expect(created.text).toContain('Rounds: 0/256') + expect(created.text).toContain('Activation: armed') + expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release') + expect(test.session.events.map(event => event.type)).toEqual(['turn/start', 'context/message', 'turn/end']) + + const count = test.session.events.length + await expect(run(test, ' replacement')).resolves.toEqual({ + kind: 'error', + text: 'A goal is already active. Use /goal edit to change it or /goal clear before replacing it.', + }) + expect(test.session.events).toHaveLength(count) + }) + + it('treats only exact control words as controls', async () => { + const test = await harness() + await run(test, ' pause everything only after verification') + expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification') + }) + + it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => { + const empty = await harness() + const invalidEdit = await run(empty, ' edit') + expect(invalidEdit.kind).toBe('error') + expect(invalidEdit.text).toContain('requires a replacement objective') + const missingEdit = await run(empty, ' edit replacement') + expect(missingEdit.kind).toBe('error') + expect(missingEdit.text).toContain('/goal edit requires one') + + const test = await harness() + await run(test, ' first') + const first = test.ctx.goals.get(test.agent)! + const updated = await run(test, ' EDIT\n second ') + expect(updated.kind).toBe('success') + expect(updated.text).toContain('Goal updated') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 }) + + const current = test.ctx.goals.get(test.agent)! + test.ctx.goals.complete(test.agent, ref(current)) + const replacement = await run(test, ' edit third') + expect(replacement.kind).toBe('success') + expect(replacement.text).toContain('Goal created') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 }) + expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id) + }) + + it('returns direct missing-state results for pause, resume, and clear', async () => { + const test = await harness() + const missingPause = await run(test, ' pause') + expect(missingPause.kind).toBe('error') + expect(missingPause.text).toContain('/goal pause requires one') + const missingResume = await run(test, ' resume') + expect(missingResume.kind).toBe('error') + expect(missingResume.text).toContain('/goal resume requires one') + await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' }) + }) + + it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => { + const test = await harness() + await run(test, ' work') + const redundantResume = await run(test, ' RESUME') + expect(redundantResume.kind).toBe('error') + expect(redundantResume.text).toContain('already active and armed') + const paused = await run(test, ' PAUSE') + expect(paused.kind).toBe('success') + expect(paused.text).toContain('Goal paused') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' }) + const resumed = await run(test, ' resume') + expect(resumed.kind).toBe('success') + expect(resumed.text).toContain('Goal resumed') + expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' }) + await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' }) + expect(test.ctx.goals.get(test.agent)).toBeUndefined() + }) + + it('shows every durable phase and distinguishes disarmed active state', async () => { + const test = await harness() + test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 }) + test.ctx.goals.disarm(test.agent) + expect((await run(test)).text) + .toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed') + expect((await run(test)).text).toContain('/goal resume') + + let goal = test.ctx.goals.get(test.agent)! + goal = test.ctx.goals.resume(test.agent, ref(goal)) + goal = test.ctx.goals.pause(test.agent, ref(goal)) + expect((await run(test)).text).toContain('Status: paused') + + goal = test.ctx.goals.resume(test.agent, ref(goal)) + goal = test.ctx.goals.block(test.agent, ref(goal)) + expect((await run(test)).text).toContain('Status: blocked') + + goal = test.ctx.goals.resume(test.agent, ref(goal)) + goal = test.ctx.goals.markUsageLimited(test.agent, ref(goal)) + expect((await run(test)).text).toContain('Status: usage limited') + + goal = test.ctx.goals.resume(test.agent, ref(goal)) + appendRound(test, goal) + goal = test.ctx.goals.get(test.agent)! + goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal)) + const limited = await run(test) + expect(limited.text).toContain('Status: limited by round budget') + expect(limited.text).not.toContain('/goal resume') + + goal = test.ctx.goals.complete(test.agent, ref(goal)) + const complete = await run(test) + expect(complete.text).toContain('Status: complete') + expect(complete.text).toContain('Commands: /goal , /goal clear') + }) + + it('does not turn unexpected implementation failures into expected command results', async () => { + const test = await harness() + vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') }) + await expect(run(test)).rejects.toThrow('unexpected failure') + }) +}) diff --git a/packages/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json new file mode 100644 index 0000000000..914a50c326 --- /dev/null +++ b/packages/goal/command-goal/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../ui/commands" + }, + { + "path": "../goal" + } + ] +} diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index b2332307f8..0e6934c2e2 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -69,5 +69,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged. - **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. - **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. -- **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **No scheduling or direct human rendering** — these tools mutate state only; the same-session driver and [`dsh-command-goal`](../command-goal/README.md) are independent consumers of the same domain. - **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49cd73a6c4..1a45c8b505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -676,6 +676,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands @@ -719,6 +722,12 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../fs/fs-local + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../util/home @@ -746,6 +755,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../skill/tool-skill @@ -837,6 +849,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../ui/app-boot + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands @@ -1000,6 +1015,30 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/command-goal: + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../goal + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/goal/goal: dependencies: schemastery: @@ -2635,6 +2674,12 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-home': specifier: workspace:^ version: link:../../packages/util/home @@ -2743,6 +2788,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 161c5c3460..e49d7a5a03 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -24,6 +24,8 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", @@ -60,6 +62,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/tsconfig.json b/tsconfig.json index 0e012a0ed2..085c533130 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -40,6 +40,7 @@ { "path": "./packages/goal/goal" }, { "path": "./packages/goal/tool-goal" }, { "path": "./packages/goal/goal-session" }, + { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, From d139948ed28fff7470abf21cfd8e2760a731fd9e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:06:21 +0800 Subject: [PATCH 043/132] feat(persistence): compress JSONL logs with Zstandard --- AGENTS.md | 2 +- docs/config-catalog.md | 25 +- .../acp-agent/advanced.cordis.snapshot.yml | 1 + examples/acp-agent/advanced.cordis.yml | 1 + .../acp-agent/both-mode.cordis.snapshot.yml | 1 + examples/acp-agent/both-mode.cordis.yml | 1 + ...mode-workspace-context.cordis.snapshot.yml | 1 + .../code-mode-workspace-context.cordis.yml | 1 + .../acp-agent/code-mode.cordis.snapshot.yml | 1 + examples/acp-agent/code-mode.cordis.yml | 1 + examples/acp-agent/cordis.yml | 2 + .../workspace-context.cordis.snapshot.yml | 1 + .../acp-agent/workspace-context.cordis.yml | 1 + examples/echo-agent/README.md | 2 +- .../fixtures/context/time-context/cordis.yml | 1 + examples/headless-agent/advanced.cordis.yml | 1 + examples/headless-agent/cordis.yml | 1 + .../headless-agent/tests/keyless-smoke.e2e.ts | 16 +- .../jsonrpc-agent/tests/keyless-smoke.e2e.ts | 12 +- .../bash/tool-bash/tests/integration.spec.ts | 4 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/src/index.ts | 13 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 11 +- .../examples/acp-demo/tests/built-bin.e2e.ts | 55 +- packages/examples/cli-demo/README.md | 1 + packages/examples/cli-demo/src/index.ts | 13 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 12 +- .../examples/cli-demo/tests/cli-demo.spec.ts | 2 + packages/examples/cli-demo/tests/cli.spec.ts | 2 +- packages/examples/stdio-demo/README.md | 1 + packages/examples/stdio-demo/src/index.ts | 13 +- .../stdio-demo/tests/built-bin.e2e.ts | 11 +- .../stdio-demo/tests/stdio-agent.spec.ts | 5 + .../session-persistence-jsonl/README.md | 23 +- .../session-persistence-jsonl/src/format.ts | 26 +- .../session-persistence-jsonl/src/index.ts | 239 +++++++-- .../session-persistence-jsonl/src/zstd.ts | 116 +++++ .../tests/jsonl.spec.ts | 80 +-- .../tests/zstd.compat.spec.ts | 24 + .../tests/zstd.spec.ts | 483 ++++++++++++++++++ packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 10 +- python/sdk/tests/manual_sdk_agent_smoke.py | 11 +- scripts/run-gates.ts | 7 +- scripts/smoke-python-runtime.py | 11 +- 45 files changed, 1113 insertions(+), 135 deletions(-) create mode 100644 packages/session-persistence/session-persistence-jsonl/src/zstd.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts diff --git a/AGENTS.md b/AGENTS.md index cd3505ea8b..e4307152df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ pnpm run hygiene out=$(printf 'echo ci smoke\n' | pnpm run demo:echo 2>&1) printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' -test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" +test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl.zstd' -type f -print -quit)" rm -rf .sessions pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f595e6574b..d590d86c71 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -58,6 +58,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ @@ -69,9 +71,9 @@ export interface Config { } ``` -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:36`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -231,6 +233,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** Skill registry, local-provider, and model-facing consumer config. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-spine-demo. */ @@ -242,9 +246,9 @@ export interface Config { } ``` -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/cli-demo/src/index.ts:22`](../packages/examples/cli-demo/src/index.ts) +Source: [`packages/examples/cli-demo/src/index.ts:25`](../packages/examples/cli-demo/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -686,10 +690,15 @@ export interface Config { * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. */ root: string + /** Physical encoding; defaults to checksummed Zstandard frames. */ + compression?: JsonlCompression } + +/** Physical encoding selected for JSONL session artifacts. */ +export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:24`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:36`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -854,6 +863,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ welcome?: string /** Terminal front-door selection and pi-tui presentation settings. */ @@ -886,9 +897,9 @@ export interface UiConfig { export type TerminalMode = 'auto' | 'readline' | 'tui' ``` -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:75`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:78`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index 97ae72d222..fb1050a259 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -13,6 +13,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index fee31ebc0d..2765b384fe 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -11,6 +11,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 85c1ff8239..de424bad0d 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -15,6 +15,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index 6b554abefb..e44f3450de 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -13,6 +13,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml index a741091cce..8160c4ac23 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -14,6 +14,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index b932f06e64..f2f24ca3a5 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -11,6 +11,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index f672c7a8d7..2730ee8a87 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -15,6 +15,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index fee39ef22a..38d8eb33cb 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -14,6 +14,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 tools: diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index cb8890dae7..5e45a81021 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,12 +33,14 @@ # The ACP server app: the agent-spine-demo spine + JSONL persistence + the ACP bridge. # Persistence root: $DSH_SNAPSHOT_SESSIONS_ROOT when the snapshot harness sets it # (so it can harvest / isolate the log), else ./.sessions for the demo. +# Snapshot modes use raw JSONL fixtures; ordinary runs keep the compressed default. - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 # Keep the persona to identity and behavior; tool plugins own tool guidance. diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index e0b02cbb21..1bcddb7cb8 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -15,6 +15,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: 'none' workspaceContext: maxBytes: 65536 dshHome: !!js process.cwd() + '/.dsh' diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 1b8c0279f0..962a3334fe 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -12,6 +12,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 dshHome: !!js process.cwd() + '/.dsh' diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index f397cc633f..55c0baf464 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -31,4 +31,4 @@ node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts exa Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). -The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/cwd-/` (one `.jsonl` log per session). Clean up with: `rm -rf .sessions` +The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/cwd-/` (one `.jsonl.zstd` log per session). Clean up with: `rm -rf .sessions` diff --git a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml index 9b59e2ded9..e6383c3c63 100644 --- a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml +++ b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml @@ -16,4 +16,5 @@ persona: 'Test the time-context plugin.' welcome: 'time-context e2e ready.' persistenceRoot: './.sessions' + persistenceCompression: 'none' workspaceContext: false diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml index 862a2f769b..fe553aa2b9 100644 --- a/examples/headless-agent/advanced.cordis.yml +++ b/examples/headless-agent/advanced.cordis.yml @@ -10,6 +10,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 tools: diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..43d5ca3e26 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -25,6 +25,7 @@ provider: deepseek model: deepseek-v4-flash persistenceRoot: './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 persona: | diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index 57b8660c03..4cd06aed78 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,7 @@ -import { readdir } from 'node:fs/promises' +import { readFile, readdir } from 'node:fs/promises' +import { zstdDecompress } from 'node:zlib' +import { promisify } from 'node:util' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' @@ -7,10 +10,11 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const decompress = promisify(zstdDecompress) describe('headless-agent keyless smoke', () => { it('boots the real Loader tree, runs a real bash tool round trip, and persists the turn', async () => { - let persisted = false + let persistedHeader: Record | undefined const { stdout, stderr } = await runLoaderSmoke({ label: 'headless-agent', tempDirPrefix: 'headless-agent-smoke-', @@ -20,7 +24,11 @@ describe('headless-agent keyless smoke', () => { tsconfigPath, inspect: async (cwd) => { const files = await readdir(cwd, { recursive: true }) - persisted = files.some(file => file.endsWith('.jsonl')) + const relativePath = files.find(file => file.endsWith('.jsonl.zstd')) + if (relativePath === undefined) return + const compressed = await readFile(join(cwd, relativePath)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + persistedHeader = JSON.parse((await decompress(compressed)).toString()) as Record }, }) const lines = stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) @@ -38,6 +46,6 @@ describe('headless-agent keyless smoke', () => { usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 }, }) expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP') - expect(persisted).toBe(true) + expect(persistedHeader).toMatchObject({ type: 'session' }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index c99b2c3c50..fb8ee81f64 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,14 +1,17 @@ import { spawn } from 'node:child_process' import { createServer } from 'node:http' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { zstdDecompress } from 'node:zlib' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const decompress = promisify(zstdDecompress) function waitForLine( lines: string[], @@ -152,6 +155,13 @@ describe('jsonrpc-agent keyless smoke', () => { } else { expect(child.exitCode, stderr).toBe(0) } + const sessionsRoot = join(root, '.sessions') + const files = await readdir(sessionsRoot, { recursive: true }) + const log = files.find(file => file.endsWith('.jsonl.zstd')) + expect(log).toBeDefined() + const compressed = await readFile(join(sessionsRoot, log!)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { if (child.exitCode === null) child.kill('SIGKILL') await new Promise(resolve => modelServer.close(() => { resolve() })) diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index d01167de3d..7d04dfe952 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -23,7 +23,9 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot }) + if (sessionRoot !== undefined) { + await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' }) + } await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 9ef18739c8..47a74cf732 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -36,6 +36,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | +| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index d9baa96394..317f04087b 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -15,7 +15,10 @@ import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceJsonl, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-demo' @@ -47,6 +50,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ @@ -72,6 +77,7 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, dshHome: z.string(), persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + persistenceCompression: JsonlCompressionSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, @@ -89,6 +95,9 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }) ctx.plugin(acp, { provider: config.provider, model: config.model }) } diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 10ee749f8c..9e658ba37c 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -70,10 +70,19 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { describe('dsh-acp-demo composition', () => { it('brings up the spine + persistence + the ACP bridge', async () => { - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig(), workspaceContext: false }) + const ctx = await mount({ + provider: 'mock', + model: 'mock', + persona: 'hi', + persistenceRoot: '/tmp/dsh-acp-demo-test', + persistenceCompression: 'none', + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) expect(ctx.get('agents')).toBeDefined() expect(ctx.get('sessions')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() + expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none') expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 3f90485679..fb6662291e 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -15,21 +15,24 @@ import { type SessionNotification, } from '@agentclientprotocol/sdk' import { Readable, Writable } from 'node:stream' +import { promisify } from 'node:util' +import { zstdDecompress } from 'node:zlib' import { afterEach, describe, expect, it } from 'vitest' /** * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and - * require a valid initialize response. This catches built-only settle races and stdout protocol - * leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a - * dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading. + * complete a mock-backed turn. This catches built-only settle races, stdout protocol leaks, and + * published persistence behavior that the tsx source-path smoke cannot. It skips before build; + * `--expose-internals` enables Cordis bare-plugin loading. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js') +const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', - 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', + 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths', @@ -73,18 +76,31 @@ async function makeConsumer(): Promise { const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp)) await link(dirname(resolved), dep, nm) } + await writeFile(join(dir, 'mock-llm.mjs'), [ + "import { LlmAdapter } from '@deepseek-ai/dsh-llm'", + 'class Mock extends LlmAdapter {', + ' async * stream() {', + " yield { type: 'block-start', index: 0, blockType: 'text' }", + " yield { type: 'text-delta', index: 0, text: 'ACP BUILT OK' }", + " yield { type: 'block-end', index: 0, block: { type: 'text', text: 'ACP BUILT OK' } }", + " yield { type: 'finish', reason: { kind: 'stop' } }", + ' }', + '}', + "export const name = 'built-acp-mock'", + "export const inject = ['llm']", + "export function apply(ctx) { ctx.llm.registerAdapter(['built-acp-mock'], new Mock()) }", + '', + ].join('\n')) await writeFile(join(dir, 'cordis.yml'), [ - '- id: llm-deepseek', - ' name: \'@deepseek-ai/dsh-llm-deepseek\'', - ' config:', - ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + '- id: mock-llm', + ' name: \'./mock-llm.mjs\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', ' name: \'@deepseek-ai/dsh-acp-demo\'', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', + ' provider: built-acp-mock', + ' model: built-acp-mock', ' persona: \'test agent\'', ' workspaceContext: false', '', @@ -113,14 +129,12 @@ afterEach(async () => { }) describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => { - it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => { + it('boots the published bin, completes a turn, and writes default Zstandard persistence', async () => { consumer = await makeConsumer() child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], { cwd: consumer, - // Dummy key: initialize never reaches the model, so it is never used. env: { ...process.env, - DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', DSH_HOME: join(consumer, '.dsh'), DSH_AGENTS_HOME: join(consumer, '.agents'), }, @@ -151,6 +165,18 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n // regression would exit before answering); loadSession proves the real app // mounted, not a collapsed export shape. expect(init.agentCapabilities?.loadSession).toBe(true) + const { sessionId } = await client.newSession({ cwd: consumer, mcpServers: [] }) + const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] }) + expect(result.stopReason).toBe('end_turn') + const sessionsRoot = join(consumer, '.sessions') + let log: string | undefined + await expect.poll(async () => { + log = (await readdir(sessionsRoot, { recursive: true })).find(file => file.endsWith('.jsonl.zstd')) + return log + }).toBeTypeOf('string') + const compressed = await readFile(join(sessionsRoot, log!)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: sessionId }) expect(stderr.join('')).not.toContain('without inject') // stdout purity: every emitted line is a JSON-RPC frame, no logger leak. for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) { @@ -182,7 +208,6 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise cwd, env: { ...process.env, - DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), }, diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 760a0f60e1..843473d9d4 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -19,6 +19,7 @@ The package mounts no console logger, readline UI, user-interaction service, or | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | | `persistenceRoot` | `./.sessions` | JSONL session root | +| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | ## CLI contract diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index e5c77af9ed..d51cc80b23 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -11,7 +11,10 @@ import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceJsonl, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' const DEFAULT_PERSISTENCE_ROOT = './.sessions' @@ -36,6 +39,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** Skill registry, local-provider, and model-facing consumer config. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-spine-demo. */ @@ -54,6 +59,7 @@ export const Config: z = z.object({ model: z.string().required(), maxParallelToolCalls: z.number().step(1).min(1), persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + persistenceCompression: JsonlCompressionSchema, persona: z.string(), dshHome: z.string(), skills: agentCore.SkillConfigSchema, @@ -78,5 +84,8 @@ export function apply(ctx: Context, config: Config): void { ...agentCore.pickSpineConfig(config), agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }], }) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }) } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 25043b40c4..c57f09b006 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -3,11 +3,14 @@ import { existsSync } from 'node:fs' import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' +import { zstdDecompress } from 'node:zlib' import { afterEach, describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js') +const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', @@ -140,8 +143,13 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } }) expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' }) - const files = await readdir(join(consumer, '.sessions'), { recursive: true }) - expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(3) + const sessionsRoot = join(consumer, '.sessions') + const files = await readdir(sessionsRoot, { recursive: true }) + const logs = files.filter(file => file.endsWith('.jsonl.zstd')) + expect(logs).toHaveLength(3) + const compressed = await readFile(join(sessionsRoot, logs[0]!)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' }) }, 30_000) it('keeps stdout empty for invalid argv and missing config', async () => { diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 2111ff6aa8..c248242ad1 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -51,12 +51,14 @@ describe('dsh-cli-demo app composition', () => { persona: 'Headless.', tools: { mode: 'native' }, persistenceRoot: root, + persistenceCompression: 'none', skills: await skillConfig(), workspaceContext: false, }) const [agent] = ctx.get('agents')?.roots() ?? [] expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('sessionPersistence')).toBeDefined() + expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none') expect(agent?.session.header.cwd).toBe(process.cwd()) expect(ctx.get('userInteraction')).toBeUndefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index fe61a42304..2f9fa32778 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -303,7 +303,7 @@ describe('runOneShot and executeCli', () => { expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' }) expect(agent.status).toBe('disposed') const files = await readdir(persistenceRoot, { recursive: true }) - expect(files.some(file => file.endsWith('.jsonl'))).toBe(true) + expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true) }) it('sums usage across tool steps and selects the last text-bearing assistant message', async () => { diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 4eafc9e251..e5a5f7bf42 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -37,6 +37,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | +| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `welcome` | `ready.` | terminal banner / TUI subtitle | | `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 0bf66ab007..a91ac95da6 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -18,7 +18,10 @@ import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceJsonl, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' @@ -89,6 +92,8 @@ export interface Config { dshHome?: string /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ welcome?: string /** Terminal front-door selection and pi-tui presentation settings. */ @@ -121,6 +126,7 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, dshHome: z.string(), persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + persistenceCompression: JsonlCompressionSchema, welcome: z.string().default(DEFAULT_WELCOME), ui: UiConfigSchema, skills: agentCore.SkillConfigSchema, @@ -145,7 +151,10 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) const mode = resolveTerminalMode(config.ui, isTTY) if (mode === 'readline') ctx.plugin(ConsoleExporter) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(SessionPersistenceJsonl, { + root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), + }) ctx.plugin(UserInteractionService) if (mode === 'tui') { ctx.plugin(uiTui, { diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..cfba97ecba 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -1,9 +1,11 @@ import { spawn } from 'node:child_process' -import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' +import { cp, mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { zstdDecompress } from 'node:zlib' import { afterEach, describe, expect, it } from 'vitest' /** @@ -15,6 +17,7 @@ import { afterEach, describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') +const decompress = promisify(zstdDecompress) // Symlink each required workspace package by package name so plain Node resolves its built `main`, // matching an installed dependency rather than tsconfig paths. @@ -153,6 +156,12 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(stdout).toContain('[tool call] echo') expect(stdout).toContain('[tool result] ECHO: HI') expect(code).toBe(0) + const files = await readdir(join(consumer, '.sessions'), { recursive: true }) + const log = files.find(file => file.endsWith('.jsonl.zstd')) + expect(log).toBeDefined() + const compressed = await readFile(join(consumer, '.sessions', log!)) + expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') + expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' }) }, 30_000) it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index c8ca063151..28b9c8cecd 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -86,12 +86,17 @@ describe('dsh-stdio-demo app', () => { provider: 'mock', model: 'mock', workspaceContext: false, + persistenceCompression: 'none', welcome: 'TUI ready', ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, }, true) expect(calls.map(call => call.name)).toContain('ui-tui') expect(calls.map(call => call.name)).not.toContain('ui-stdio') expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') + expect(calls.find(call => (call.config as { root?: string } | undefined)?.root === './.sessions')?.config).toEqual({ + root: './.sessions', + compression: 'none', + }) const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) expect(tuiConfig.sessionId).toMatch(/^main-session-/) diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index ed7a056c69..96ef3760ba 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -1,31 +1,39 @@ # @deepseek-ai/dsh-session-persistence-jsonl -The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). One append-only `.jsonl` event log per session. +The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). Each session has one append-only logical JSONL log, stored as `.jsonl.zstd` by default or raw `.jsonl` when compression is disabled. ## On-disk layout ``` / cwd-/ # per-project bucket (or _no-cwd/ when no cwd) - .jsonl # header line + one SessionEvent per line (verbatim) + .jsonl.zstd # default: checksummed header frame + append frames + .jsonl # only with compression: 'none' ``` -- The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength? }`; every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). -- Session ids are unvalidated branded strings, so they are percent-encoded to a single safe path segment before use (no traversal, no collision). +- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength? }`; every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). +- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). ## Config | Key | Type | Notes | |---|---|---| | `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). | +| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. | `locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix. +## Physical encoding + +The default artifact is a standard concatenation of independent [Zstandard frames](../../../docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. + +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. There is no migration, mixed-root fallback, or dual write. + ## Durability and crash semantics - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`. -- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. +- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. +- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. ## Write path @@ -50,7 +58,8 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr ## Known Limitations and Deferred Work -- **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration. +- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. +- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. - **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 39bdecf751..e9c93562e6 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -12,8 +12,20 @@ import { createHash } from 'node:crypto' import { join } from 'node:path' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +/** Physical encoding selected for JSONL session artifacts. */ +export type JsonlCompression = 'zstd' | 'none' + /** - * The first line of a session's `.jsonl` file: the immutable + * Return the artifact suffix for one physical encoding. + * @param compression - configured JSONL artifact encoding. + * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext. + */ +export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' { + return compression === 'zstd' ? '.jsonl.zstd' : '.jsonl' +} + +/** + * The first JSONL record of a session artifact: the immutable * {@link SessionHeader} tagged as a `session` record so a reader can tell it * apart from an event line. */ @@ -119,10 +131,16 @@ export function sessionDir(root: string, cwd: string | undefined): string { * @param root - the backend's session root directory. * @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`). * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use. - * @returns the session's `.jsonl` log file path. + * @param compression - physical artifact encoding and filename suffix. + * @returns the session's configured JSONL artifact path. */ -export function logPath(root: string, cwd: string | undefined, id: SessionId): string { - return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`) +export function logPath( + root: string, + cwd: string | undefined, + id: SessionId, + compression: JsonlCompression, +): string { + return join(sessionDir(root, cwd), `${encodeSegment(id)}${logSuffix(compression)}`) } /** diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 4e52cb0b9e..7aaf091038 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -17,8 +17,20 @@ import { } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, + encodeSegment, eventLine, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, + type JsonlCompression, } from './format.ts' +import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts' + +export type { JsonlCompression } from './format.ts' + +const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' + +/** Loader schema for the JSONL artifact's physical encoding. */ +export const JsonlCompressionSchema: z = z.union([ + z.const('zstd'), + z.const('none'), +]).default(DEFAULT_COMPRESSION) /** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */ export interface Config { @@ -28,6 +40,14 @@ export interface Config { * (bash calls, subprocesses). Sessions group under per-cwd subdirectories. */ root: string + /** Physical encoding; defaults to checksummed Zstandard frames. */ + compression?: JsonlCompression +} + +/** Opaque coordinator token for replacing bytes recovered from a torn frame. */ +interface JsonlTornMarker { + truncateTo: number + recoveredEvents: SessionEvent[] } /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ @@ -38,13 +58,15 @@ function isENOENT(error: unknown): boolean { /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence` and (via the coordinator) installs the write-path - * listeners. Its torn-tail marker is the byte offset to truncate the log to. + * listeners. Its torn-tail marker carries the byte offset and any events + * recovered from an incomplete final Zstandard frame. */ -export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend { +export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] static Config: z = z.object({ root: z.string().required(), + compression: JsonlCompressionSchema, }) /** @@ -55,7 +77,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi override readonly name = 'session-persistence-jsonl' private root: string - private coordinator: PersistenceCoordinator + private compression: JsonlCompression + private coordinator: PersistenceCoordinator + private rootEncodingCheck: Promise | undefined /** Runtime host platform used to decide whether directory sync is supported. */ readonly internals: { platform: NodeJS.Platform } = { platform: process.platform } @@ -64,7 +88,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi super(ctx) // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) - this.coordinator = new PersistenceCoordinator(this.ctx, this) + this.compression = config.compression ?? DEFAULT_COMPRESSION + this.coordinator = new PersistenceCoordinator(this.ctx, this) } // Each backend keeps the typed service surface beside its storage hooks; @@ -74,7 +99,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Resolve the absolute target path without touching the filesystem. */ locate(meta: SessionHeader): SessionLocation { - return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) } + return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id, this.compression) } } create(meta: SessionHeader): Promise { @@ -96,7 +121,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- PersistenceBackend hooks (the file-bytes storage primitives) --- /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ - async loadStored(id: SessionId): Promise | undefined> { + async loadStored(id: SessionId): Promise | undefined> { + await this.ensureRootEncoding() const file = await this.findLog(id) if (file === undefined) return undefined return this.readPrefix(file.path) @@ -106,28 +132,85 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi * Read a stored prefix within one cwd for HMR adoption. `undefined` names the * no-cwd bucket rather than an unknown cwd, so this never scans other buckets. */ - async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { - const path = logPath(this.root, cwd, id) - if (!await this.exists(path)) return undefined + async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { + await this.ensureRootEncoding() + const path = logPath(this.root, cwd, id, this.compression) + if (!await this.exists(path)) { + await this.rejectOppositeArtifact(cwd, id) + return undefined + } return this.readPrefix(path) } /** - * Read a stored prefix and convert torn-tail state to the byte offset the - * coordinator can round-trip without knowing the file format. + * Read a stored prefix and convert torn-tail state to the opaque marker the + * coordinator can round-trip without knowing the physical encoding. */ - private async readPrefix(path: string): Promise> { + private async readPrefix(path: string): Promise> { const buffer = await readFile(path) + if (this.compression === 'zstd') return this.readZstdPrefix(buffer) const { meta, events, committedBytes } = scanLog(buffer) return { meta, events, - ...committedBytes < buffer.byteLength ? { tornMarker: committedBytes } : {}, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, + } + } + + /** Decode complete frames and retain complete JSONL records from a torn final frame. */ + private async readZstdPrefix(buffer: Buffer): Promise> { + const { frames, tornStart } = scanZstdFrames(buffer) + if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') + + const plaintextFrames: Buffer[] = [] + for (const frame of frames) { + try { + plaintextFrames.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end))) + } catch (error) { + throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error }) + } + } + + const headerFrame = plaintextFrames[0] + if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) { + throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') + } + const completePlaintext = Buffer.concat(plaintextFrames) + const completePrefix = scanLog(completePlaintext) + if (completePrefix.committedBytes !== completePlaintext.length) { + throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') + } + if (tornStart === undefined) { + return { meta: completePrefix.meta, events: completePrefix.events } + } + + let recoveredPlaintext: Buffer = Buffer.alloc(0) + try { + recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart)) + } catch { + // A structurally incomplete final frame may end before Node's decoder can + // emit any plaintext; the complete prior frames remain recoverable. + } + const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext])) + /* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */ + if (recoveredPrefix.events.length < completePrefix.events.length) { + throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames') + } + return { + meta: recoveredPrefix.meta, + events: recoveredPrefix.events, + tornMarker: { + truncateTo: tornStart, + recoveredEvents: recoveredPrefix.events.slice(completePrefix.events.length), + }, } } /** Durably append a batch, lazily materializing the file when not yet present. */ async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { + await this.ensureRootEncoding() if (isMaterialized) { await this.appendLines(meta, events) } else { @@ -136,22 +219,30 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Make a crash repair durable: truncate the torn tail to `tornMarker` bytes (if - * any), then append the synthetic `closers` (if any). Two fsync'd steps — the - * seam does not require this to be atomic. + * Make a crash repair durable: truncate a torn tail, restore complete events + * decoded from it, then append synthetic closers. Two fsync'd steps — the seam + * does not require this to be atomic. */ - async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise { - if (tornMarker !== undefined) await this.repair(meta, tornMarker) - if (closers.length > 0) await this.appendLines(meta, closers) + async commitRepair( + meta: SessionHeader, + tornMarker: JsonlTornMarker | undefined, + closers: readonly SessionEvent[], + ): Promise { + if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) + const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] + if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) } /** List all stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { + await this.ensureRootEncoding() const metas: SessionHeader[] = [] for (const dir of await this.listCwdDirs()) { - for (const name of await this.listJsonl(dir)) { + for (const name of await this.listArtifacts(dir)) { // Read only headers so listing scales with session count, not log size. - const first = await this.readFirstLine(`${dir}/${name}`) + const first = this.compression === 'zstd' + ? await this.readFirstZstdLine(`${dir}/${name}`) + : await this.readFirstLine(`${dir}/${name}`) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header @@ -170,15 +261,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await this.syncDir(dirname(this.root)) await mkdir(dir, { recursive: true, mode: 0o700 }) await this.syncDir(this.root) - const finalPath = logPath(this.root, meta.cwd, meta.id) + const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression) // Materialization is the first write; an existing log is an id collision. /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ if (await this.exists(finalPath)) { throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`) } - const header = JSON.stringify(toHeaderLine(meta)) - const body = events.map(eventLine).join('\n') - const content = header + '\n' + body + '\n' + await this.rejectOppositeArtifact(meta.cwd, meta.id) + const content = await this.encodeMaterialization(meta, events) const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp` const handle = await open(tmp, 'wx', 0o600) @@ -211,6 +301,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** Encode the header and first batch without combining their frame boundaries. */ + private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { + const header = JSON.stringify(toHeaderLine(meta)) + '\n' + const body = events.map(eventLine).join('\n') + '\n' + if (this.compression === 'none') return header + body + const headerFrame = await compressZstdFrame(header) + const eventFrame = await compressZstdFrame(body) + return Buffer.concat([headerFrame, eventFrame]) + } + + /** Encode one durable append batch in the configured physical representation. */ + private async encodeEventBatch(events: readonly SessionEvent[]): Promise { + const body = events.map(eventLine).join('\n') + '\n' + return this.compression === 'zstd' ? compressZstdFrame(body) : body + } + /** fsync a directory when the host exposes that durability primitive. */ private async syncDir(dir: string): Promise { const handle = await open(dir, 'r') @@ -234,12 +340,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi * batch; leaving partial bytes would create duplicate sequence numbers. */ private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise { - const path = logPath(this.root, meta.cwd, meta.id) + const content = await this.encodeEventBatch(events) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) const handle = await open(path, 'a') try { const { size: before } = await handle.stat() try { - await handle.writeFile(events.map(eventLine).join('\n') + '\n') + await handle.writeFile(content) await handle.sync() } catch (error) { // Roll back whatever bytes landed so a retry starts from a clean EOF. @@ -254,7 +361,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */ private async repair(meta: SessionHeader, offset: number): Promise { - const path = logPath(this.root, meta.cwd, meta.id) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) await truncate(path, offset) const handle = await open(path, 'r+') try { @@ -292,17 +399,47 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** Read and validate only the independently compressed header frame. */ + private async readFirstZstdLine(path: string): Promise { + const handle = await open(path, 'r') + try { + let content = Buffer.alloc(0) + const chunk = Buffer.alloc(8192) + for (;;) { + const { bytesRead } = await handle.read(chunk, 0, chunk.length, null) + if (bytesRead === 0) return undefined + content = Buffer.concat([content, chunk.subarray(0, bytesRead)]) + const first = scanZstdFrames(content, 1).frames[0] + if (first === undefined) continue + let plaintext: Buffer + try { + plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) + } catch (error) { + throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) + } + if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { + throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') + } + return plaintext.subarray(0, -1).toString('utf8') + } + } finally { + await handle.close() + } + } + /** * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption * bypasses this scan so a no-cwd session cannot claim another bucket. */ private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { - const target = encodeSegment(id) + '.jsonl' + const target = encodeSegment(id) + logSuffix(this.compression) for (const dir of await this.listCwdDirs()) { const path = `${dir}/${target}` + const opposite = `${dir}/${encodeSegment(id)}${logSuffix(this.oppositeCompression())}` + if (await this.exists(opposite)) throw this.encodingMismatch(opposite) if (await this.exists(path)) { // Recover the cwd from the header so the caller has the session's bucket. - const { meta } = scanLog(await readFile(path)) + const { meta } = await this.readPrefix(path) return { path, cwd: meta.cwd } } } @@ -321,9 +458,45 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - private async listJsonl(dir: string): Promise { + private async listArtifacts(dir: string): Promise { const entries = await readdir(dir) - return entries.filter(n => n.endsWith('.jsonl')) + const oppositeSuffix = logSuffix(this.oppositeCompression()) + const incompatible = entries.find(name => name.endsWith(oppositeSuffix)) + if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`) + const suffix = logSuffix(this.compression) + return entries.filter(name => name.endsWith(suffix)) + } + + /** Reject a root that already belongs to the other physical encoding. */ + private ensureRootEncoding(): Promise { + this.rootEncodingCheck ??= this.checkRootEncoding() + return this.rootEncodingCheck + } + + private async checkRootEncoding(): Promise { + const oppositeSuffix = logSuffix(this.oppositeCompression()) + for (const dir of await this.listCwdDirs()) { + const entries = await readdir(dir) + const incompatible = entries.find(name => name.endsWith(oppositeSuffix)) + if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`) + } + } + + private async rejectOppositeArtifact(cwd: string | undefined, id: SessionId): Promise { + const path = logPath(this.root, cwd, id, this.oppositeCompression()) + if (await this.exists(path)) throw this.encodingMismatch(path) + } + + private oppositeCompression(): JsonlCompression { + return this.compression === 'zstd' ? 'none' : 'zstd' + } + + private encodingMismatch(path: string): Error { + return new Error( + `session artifact ${JSON.stringify(path)} uses ${logSuffix(this.oppositeCompression())}, ` + + `but this backend is configured for compression ${JSON.stringify(this.compression)}; ` + + 'use a separate root or select the matching compression mode', + ) } private async exists(path: string): Promise { diff --git a/packages/session-persistence/session-persistence-jsonl/src/zstd.ts b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts new file mode 100644 index 0000000000..bba2ef6344 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/zstd.ts @@ -0,0 +1,116 @@ +/** + * Zstandard frame primitives for the JSONL persistence backend. The backend + * owns a concatenated-frame container so it can append and recover batches + * without exposing compression mechanics through the persistence seam. + * @module dsh-session-persistence-jsonl/zstd + */ + +import { constants, zstdCompress, zstdDecompress, type ZstdOptions } from 'node:zlib' +import { promisify } from 'node:util' + +const ZSTD_MAGIC = 0xFD2FB528 +const zstdCompressAsync = promisify(zstdCompress) +const zstdDecompressAsync = promisify(zstdDecompress) +const CHECKSUM_OPTIONS: ZstdOptions = { + params: { [constants.ZSTD_c_checksumFlag]: 1 }, +} + +/** Byte range occupied by one structurally complete Zstandard frame. */ +export interface ZstdFrameRange { + /** Inclusive frame start. */ + start: number + /** Exclusive frame end. */ + end: number +} + +/** Structural scan result for a concatenated Zstandard stream. */ +export interface ZstdFrameScan { + /** Complete frames in file order. */ + frames: ZstdFrameRange[] + /** Start of an incomplete final frame, when EOF interrupts one. */ + tornStart?: number +} + +/** + * Locate complete frames without decompressing their blocks. Invalid complete + * structure rejects; EOF inside the final frame returns its start for repair. + * @param buffer - complete bytes currently present in the session artifact. + * @param maxFrames - optional complete-frame limit for metadata-only readers. + * @returns complete frame ranges and an optional incomplete-final-frame start. + */ +export function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): ZstdFrameScan { + const frames: ZstdFrameRange[] = [] + let offset = 0 + + while (offset < buffer.length) { + const start = offset + if (buffer.length - offset < 4) return { frames, tornStart: start } + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`) + } + offset += 4 + + if (offset === buffer.length) return { frames, tornStart: start } + const descriptor = buffer.readUInt8(offset) + offset += 1 + if ((descriptor & 0x18) !== 0) { + throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`) + } + + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 0x20) !== 0 + const checksum = (descriptor & 0x04) !== 0 + const dictionaryFlag = descriptor & 0x03 + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag + const contentSizeBytes = contentSizeFlag === 0 + ? (singleSegment ? 1 : 0) + : 1 << contentSizeFlag + const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes + if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } + offset += remainingHeaderBytes + + for (;;) { + if (buffer.length - offset < 3) return { frames, tornStart: start } + const blockHeader = buffer.readUIntLE(offset, 3) + offset += 3 + const lastBlock = (blockHeader & 1) !== 0 + const blockType = (blockHeader >>> 1) & 0x03 + const blockSize = blockHeader >>> 3 + if (blockType === 0x03) { + throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`) + } + const payloadBytes = blockType === 0x01 ? 1 : blockSize + if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } + offset += payloadBytes + if (lastBlock) break + } + + if (checksum) { + if (buffer.length - offset < 4) return { frames, tornStart: start } + offset += 4 + } + frames.push({ start, end: offset }) + if (frames.length === maxFrames) return { frames } + } + + return { frames } +} + +/** + * Compress one independently decodable, checksummed Zstandard frame. + * @param input - JSONL bytes for a header or durable event batch. + * @returns the complete encoded frame. + */ +export async function compressZstdFrame(input: Buffer | string): Promise { + return zstdCompressAsync(input, CHECKSUM_OPTIONS) +} + +/** + * Decompress one complete frame or the available prefix of a torn final frame. + * Complete-frame checksums are validated by Node's decoder. + * @param input - bytes beginning at a Zstandard frame boundary. + * @returns plaintext produced from the available input. + */ +export async function decompressZstdFrame(input: Buffer): Promise { + return zstdDecompressAsync(input) +} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e7dc469132..2d3cc40903 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -40,6 +40,10 @@ async function freshRoot(): Promise { return dir } +function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string { + return logPath(root, cwd, id, 'none') +} + afterEach(async () => { vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) @@ -70,11 +74,11 @@ function appendClosedTurn(session: Session): void { } // Run the shared backend contract against the real JSONL backend. -runPersistenceContract('jsonl', async () => { +runPersistenceContract('jsonl-none', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir }) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) return { persistence: ctx.sessionPersistence, dispose: async () => { @@ -86,18 +90,18 @@ runPersistenceContract('jsonl', async () => { // Two mounts share this temp root to exercise reload. `corruptTail` appends a partial, // newline-less fragment past the committed region so coordinator repair runs on real file bytes. -runCoordinatorContract('jsonl', async (): Promise => { +runCoordinatorContract('jsonl-none', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) return { mount: async (ctx) => { - const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir }) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) return fiber }, corruptTail: async (id, cwd) => { // A half-written record with no trailing newline: scanLog treats it as an // uncommitted crash fragment and reports committedBytes < byteLength, so // the coordinator sees a tornMarker to truncate. - await appendFile(logPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti') + await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti') }, cleanup: async () => { await rm(dir, { recursive: true, force: true }) }, } @@ -134,11 +138,14 @@ describe('SessionPersistenceJsonl: format helpers', () => { const absoluteRoot = await freshRoot() const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: relative(process.cwd(), absoluteRoot) }) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { + root: relative(process.cwd(), absoluteRoot), + compression: 'none', + }) const m = meta('relative-location', '/work') expect(ctx.sessionPersistence.locate(m)).toEqual({ kind: 'jsonl', - path: logPath(resolve(absoluteRoot), '/work', m.id), + path: rawLogPath(resolve(absoluteRoot), '/work', m.id), }) await fiber.dispose() }) @@ -150,26 +157,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { root = await freshRoot() ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) }) afterEach(async () => { await ctx.fiber.dispose() }) it('lazy materialization: create() writes no file until the first append', async () => { const m = meta('lazy', '/work') const location = ctx.sessionPersistence.locate(m) - expect(location).toEqual({ kind: 'jsonl', path: logPath(root, '/work', m.id) }) + expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) }) expect(isAbsolute(location!.path)).toBe(true) await ctx.sessionPersistence.create(m) // locate() is a pure target-path calculation: neither it nor create() // materializes a file before the first append. const dir = sessionDir(root, '/work') - await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow() + await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow() expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id) await ctx.sessionPersistence.append(m.id, oneTurnLog()) // now materialized - expect((await stat(logPath(root, '/work', m.id))).isFile()).toBe(true) + expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) void dir }) @@ -191,7 +198,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { } const childLocation = ctx.sessionPersistence.locate(child) expect(childLocation?.path).not.toBe(parentLocation?.path) - expect(childLocation).toEqual({ kind: 'jsonl', path: logPath(root, '/work', child.id) }) + expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) }) }) it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => { @@ -213,7 +220,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { const m = meta('legacy-header-delta', '/legacy') - const path = logPath(root, m.cwd, m.id) + const path = rawLogPath(root, m.cwd, m.id) await mkdir(sessionDir(root, m.cwd), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), @@ -228,7 +235,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { it('rejects a stored v0 full header carrying the legacy fallback reason', async () => { const m = meta('legacy-header-fallback', '/legacy') - const path = logPath(root, m.cwd, m.id) + const path = rawLogPath(root, m.cwd, m.id) await mkdir(sessionDir(root, m.cwd), { recursive: true }) await writeFile(path, [ JSON.stringify(toHeaderLine(m)), @@ -270,7 +277,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // Simulate a crash mid-second-turn: append raw lines that are NOT closed by // a turn/end (turn/start + step/start are fully written), plus a final // partial line with no newline (a torn fragment never fully flushed). - const path = logPath(root, '/proj', m.id) + const path = rawLogPath(root, '/proj', m.id) await writeFile(path, [ JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }), @@ -303,17 +310,17 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { const m = meta('append-only') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) - const before = await readFile(logPath(root, undefined, m.id), 'utf8') + const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8') const committedPrefix = before // the whole committed log // A crash tail then a repair-append. - await writeFile(logPath(root, undefined, m.id), '\n{"partial', { flag: 'a' }) + await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' }) await ctx.sessionPersistence.load(m.id) await ctx.sessionPersistence.append(m.id, [ { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, ] as SessionEvent[]) - const after = await readFile(logPath(root, undefined, m.id), 'utf8') + const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8') // the committed prefix is byte-for-byte intact at the head of the file expect(after.startsWith(committedPrefix)).toBe(true) }) @@ -322,12 +329,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { const m = meta('truncate-retry') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5 - const sizeBefore = (await stat(logPath(root, undefined, m.id))).size + const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile // has already put bytes on disk — simulating an ENOSPC/fsync error // mid-append. The recovery truncate() also fsyncs, so allow that one. - const handle = await (await import('node:fs/promises')).open(logPath(root, undefined, m.id), 'r') + const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r') const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } await handle.close() const realSync = proto.sync @@ -344,7 +351,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { // The append rejects, but the partial bytes are truncated back: the file is // its pre-append size and the cursor is unchanged. await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/) - expect((await stat(logPath(root, undefined, m.id))).size).toBe(sizeBefore) + expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore) spy.mockRestore() // The retry now succeeds with NO seq gap — the log is contiguous 0..7. @@ -425,7 +432,7 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () => root = await freshRoot() const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) const a = ctx.sessions.create(SessionId('sa')) const b = ctx.sessions.create(SessionId('sb')) @@ -531,7 +538,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { root = await freshRoot() ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root }) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) }) afterEach(async () => { await ctx.fiber.dispose() }) @@ -551,8 +558,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { await p await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog()) // The log materialized under the ORIGINAL cwd, not the mutated one. - expect((await stat(logPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true) - await expect(stat(logPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow() + expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true) + await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow() }) it('list discovers sessions across multiple cwd buckets', async () => { @@ -633,7 +640,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // of grafting no-cwd events onto a log with mismatched cwd. const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) + await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) let b!: Session await ctx2.plugin(Object.assign((inner: Context) => { b = inner.sessions.create(SessionId('x')) // no cwd @@ -642,10 +649,10 @@ describe('SessionPersistenceJsonl: edge cases', () => { // The "/w" log is untouched — no no-cwd events were grafted onto it, and no // `_no-cwd` log for "x" was created. - const inW = scanLog(await readFile(logPath(root, '/w', SessionId('x')))) + const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x')))) expect(inW.meta.cwd).toBe('/w') expect(inW.events).toHaveLength(6) - await expect(stat(logPath(root, undefined, SessionId('x')))).rejects.toThrow() + await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow() await ctx2.fiber.dispose() }) @@ -689,7 +696,10 @@ describe('SessionPersistenceJsonl: edge cases', () => { it('list returns nothing when the root directory does not exist', async () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root: join(root, 'does-not-exist-yet') }) + await ctx2.plugin(SessionPersistenceJsonl, { + root: join(root, 'does-not-exist-yet'), + compression: 'none', + }) expect(await ctx2.sessionPersistence.list()).toEqual([]) await ctx2.fiber.dispose() }) @@ -701,7 +711,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await writeFile(filePath, 'x') const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root: filePath }) + await ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' }) await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() }) @@ -712,7 +722,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) + await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { @@ -727,14 +737,14 @@ describe('SessionPersistenceJsonl: edge cases', () => { const m = meta('disk-append', '/d') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await writeFile(logPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' }) + await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' }) // A FRESH backend with no in-memory state: append directly (no prior load) // → append must adopt from disk, and the adopt's load schedules a repair // that the same append then performs before writing. const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) + await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) await ctx2.sessionPersistence.append(m.id, [ { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } }, @@ -770,7 +780,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // nondeterministic. create scans every bucket, not just meta.cwd's. const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) + await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB'))) .rejects.toThrow(/already has a persisted log on disk/) await ctx2.fiber.dispose() @@ -780,7 +790,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { root = await freshRoot() const ctx2 = new Context() await ctx2.plugin(SessionStore) - await ctx2.plugin(SessionPersistenceJsonl, { root }) + await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) const session = ctx2.sessions.create(SessionId('flush-fail')) // A full turn lands in the write-behind buffer. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts new file mode 100644 index 0000000000..bd552e738e --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts' + +describe('JSONL Zstandard compatibility', () => { + it('round-trips concatenated checksummed frames through the built-in Node API', async () => { + const encoded = Buffer.concat([ + await compressZstdFrame('{"type":"session","version":0,"id":"compat","createdAt":1}\n'), + await compressZstdFrame('{"type":"turn/start","seq":0,"turn":1}\n'), + ]) + const { frames, tornStart } = scanZstdFrames(encoded) + + expect(tornStart).toBeUndefined() + expect(frames).toHaveLength(2) + expect(frames.map(frame => encoded.subarray(frame.start, frame.start + 4).toString('hex'))) + .toEqual(['28b52ffd', '28b52ffd']) + const decoded = await Promise.all(frames.map(frame => decompressZstdFrame(encoded.subarray(frame.start, frame.end)))) + expect(Buffer.concat(decoded).toString()).toContain('"type":"turn/start"') + + const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end) + const missingChecksumByte = eventFrame.subarray(0, -1) + expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 }) + expect((await decompressZstdFrame(missingChecksumByte)).toString()).toContain('"type":"turn/start"') + }) +}) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts new file mode 100644 index 0000000000..830e17ffc7 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -0,0 +1,483 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { eventLine, logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts' +import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts' +import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' +import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' + +const MAGIC = Buffer.from([0x28, 0xB5, 0x2F, 0xFD]) +const roots: string[] = [] +const contexts: Context[] = [] + +async function freshRoot(prefix = 'dsh-jsonl-zstd-'): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function mount(root: string, compression?: JsonlCompression): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { + root, + ...(compression === undefined ? {} : { compression }), + }) + return ctx +} + +async function decodeCompleteFrames(buffer: Buffer): Promise { + const { frames, tornStart } = scanZstdFrames(buffer) + expect(tornStart).toBeUndefined() + const plaintext: Buffer[] = [] + for (const frame of frames) { + plaintext.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end))) + } + return Buffer.concat(plaintext) +} + +async function tornFrame( + plaintext: string, + accepts: (decoded: string) => boolean, +): Promise { + const frame = await compressZstdFrame(plaintext) + const candidateEnds = [ + frame.length - 1, + frame.length - 4, + ...[0.9, 0.75, 0.6, 0.5, 0.4, 0.25].map(ratio => Math.floor(frame.length * ratio)), + ] + for (const end of candidateEnds) { + const candidate = frame.subarray(0, end) + if (scanZstdFrames(candidate).tornStart !== 0) continue + try { + const decoded = (await decompressZstdFrame(candidate)).toString('utf8') + if (accepts(decoded)) return candidate + } catch { + // Some early cuts precede the first decodable block; keep searching for + // a cut that exercises partial-plaintext recovery. + } + } + throw new Error('test fixture could not produce the requested torn Zstandard frame') +} + +function deterministicNoise(length: number): string { + let state = 0x12345678 + let output = '' + for (let index = 0; index < length; index++) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + output += String.fromCharCode(33 + (state % 90)) + } + return output +} + +function emptyStructuralFrame(descriptor: number): Buffer { + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 0x20) !== 0 + const dictionaryBytes = [0, 1, 2, 4][descriptor & 0x03]! + const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag + const variableHeader = Buffer.alloc((singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes) + const lastEmptyRawBlock = Buffer.from([1, 0, 0]) + const checksum = (descriptor & 0x04) === 0 ? Buffer.alloc(0) : Buffer.alloc(4) + return Buffer.concat([MAGIC, Buffer.from([descriptor]), variableHeader, lastEmptyRawBlock, checksum]) +} + +afterEach(async () => { + vi.restoreAllMocks() + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +runPersistenceContract('jsonl-zstd', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-contract-')) + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root }) + return { + persistence: ctx.sessionPersistence, + dispose: async () => { + await fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +}) + +runCoordinatorContract('jsonl-zstd', async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-coordinator-')) + return { + mount: async ctx => ctx.plugin(SessionPersistenceJsonl, { root }), + corruptTail: async (id, cwd) => { + const line = JSON.stringify({ + type: 'assistant/chunk', + seq: 8, + time: 9, + data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } }, + }) + '\n' + const partial = await tornFrame(line, decoded => decoded.length > 0 && !decoded.endsWith('\n')) + await appendFile(logPath(root, cwd, id, 'zstd'), partial) + }, + cleanup: async () => { await rm(root, { recursive: true, force: true }) }, + } +}) + +describe('Zstandard frame structure', () => { + it('scans concatenated checksummed frames and honors a frame limit', async () => { + const first = await compressZstdFrame('header\n') + const second = await compressZstdFrame('event\n') + const stream = Buffer.concat([first, second]) + expect(scanZstdFrames(Buffer.alloc(0))).toEqual({ frames: [] }) + expect(scanZstdFrames(stream)).toEqual({ + frames: [{ start: 0, end: first.length }, { start: first.length, end: stream.length }], + }) + expect(scanZstdFrames(stream, 1)).toEqual({ frames: [{ start: 0, end: first.length }] }) + expect(first[4]! & 0x04).toBe(0x04) + expect(second[4]! & 0x04).toBe(0x04) + expect((await decompressZstdFrame(first)).toString()).toBe('header\n') + }) + + it('distinguishes incomplete frame regions from invalid complete structure', () => { + expect(scanZstdFrames(MAGIC.subarray(0, 2))).toEqual({ frames: [], tornStart: 0 }) + expect(scanZstdFrames(MAGIC)).toEqual({ frames: [], tornStart: 0 }) + expect(() => scanZstdFrames(Buffer.alloc(4))).toThrow(/invalid frame magic/) + expect(() => scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x08])]))).toThrow(/reserved frame-header bit/) + + // Non-single-segment descriptor with no window descriptor. + expect(scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x00])]))).toEqual({ frames: [], tornStart: 0 }) + // Single-segment header followed by only two bytes of the three-byte block header. + expect(scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x20, 0x00, 0x01, 0x00])]))).toEqual({ + frames: [], + tornStart: 0, + }) + + const rawFiveBytes = Buffer.from([(5 << 3) | 1, 0, 0]) + expect(scanZstdFrames(Buffer.concat([ + MAGIC, + Buffer.from([0x20, 0x00]), + rawFiveBytes, + Buffer.from([0x01, 0x02]), + ]))).toEqual({ frames: [], tornStart: 0 }) + + const reservedBlock = Buffer.concat([ + MAGIC, + Buffer.from([0x20, 0x00, 0x07, 0x00, 0x00]), + ]) + expect(() => scanZstdFrames(reservedBlock)).toThrow(/reserved block type/) + }) + + it('covers standard header variants, RLE blocks, multiple blocks, and checksums', () => { + for (const descriptor of [0x00, 0x21, 0x42, 0x83, 0xE3]) { + const frame = emptyStructuralFrame(descriptor) + expect(scanZstdFrames(frame)).toEqual({ frames: [{ start: 0, end: frame.length }] }) + } + + const rle = Buffer.concat([ + MAGIC, + Buffer.from([0x20, 0x01]), + Buffer.from([(1 << 3) | (1 << 1) | 1, 0, 0]), + Buffer.from([0x41]), + ]) + expect(scanZstdFrames(rle)).toEqual({ frames: [{ start: 0, end: rle.length }] }) + + const twoBlocks = Buffer.concat([ + MAGIC, + Buffer.from([0x20, 0x00]), + Buffer.from([0, 0, 0]), + Buffer.from([1, 0, 0]), + ]) + expect(scanZstdFrames(twoBlocks)).toEqual({ frames: [{ start: 0, end: twoBlocks.length }] }) + + const checksummed = emptyStructuralFrame(0x24) + expect(scanZstdFrames(checksummed.subarray(0, -1))).toEqual({ frames: [], tornStart: 0 }) + expect(scanZstdFrames(checksummed)).toEqual({ frames: [{ start: 0, end: checksummed.length }] }) + }) +}) + +describe('SessionPersistenceJsonl: default Zstandard encoding', () => { + it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('default-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const path = logPath(root, header.cwd, header.id, 'zstd') + const buffer = await readFile(path) + expect(buffer.subarray(0, 4)).toEqual(MAGIC) + await expect(stat(logPath(root, header.cwd, header.id, 'none'))).rejects.toThrow() + expect(ctx.sessionPersistence.locate(header)).toEqual({ kind: 'jsonl', path }) + + const scan = scanZstdFrames(buffer) + expect(scan.frames).toHaveLength(2) + const plaintext = await decodeCompleteFrames(buffer) + expect(plaintext.toString()).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(eventLine), + '', + ].join('\n')) + expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) + }) + + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { + const root = await freshRoot() + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + let backend!: SessionPersistenceJsonl + await ctx.plugin(Object.assign((inner: Context) => { + backend = new SessionPersistenceJsonl(inner, { root }) + }, { inject: ['sessions'] })) + const header = meta('direct-default') + expect(backend.locate(header)).toEqual({ + kind: 'jsonl', + path: logPath(root, header.cwd, header.id, 'zstd'), + }) + }) + + it('appends one frame per durable batch without rewriting prior bytes', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('append-frame') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const before = await readFile(path) + const secondTurn = [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await ctx.sessionPersistence.append(header.id, secondTurn) + + const after = await readFile(path) + expect(after.subarray(0, before.length)).toEqual(before) + expect(scanZstdFrames(after).frames).toHaveLength(3) + expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) + }) + + it('lists from a multi-chunk header frame without decoding a corrupt event frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('large-header', `/work/${'x'.repeat(24_000)}`) + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const buffer = Buffer.from(await readFile(path)) + const eventFrame = scanZstdFrames(buffer).frames[1]! + buffer[eventFrame.end - 1] = buffer[eventFrame.end - 1]! ^ 0xFF + await writeFile(path, buffer) + + expect((await ctx.sessionPersistence.list()).map(item => item.id)).toEqual([header.id]) + await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/) + }) + + it('preserves complete records from a torn frame and re-encodes them with crash closers', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('recover-torn', '/proj') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const committed = await readFile(path) + const openTurn = [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } }, + ] as SessionEvent[] + const plaintext = openTurn.map(eventLine).join('\n') + '\n' + const partial = await tornFrame(plaintext, (decoded) => { + const newlines = decoded.match(/\n/g)?.length ?? 0 + return newlines >= 2 && !decoded.endsWith('\n') + }) + await appendFile(path, partial) + + const loaded = await ctx.sessionPersistence.load(header.id) + expect(loaded.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(loaded.events[6]).toEqual(openTurn[0]) + expect(loaded.events[7]).toEqual(openTurn[1]) + expect(loaded.events.some(event => event.type === 'assistant/chunk' && event.seq === 8)).toBe(false) + expect(loaded.events[8]?.type).toBe('step/end') + expect(loaded.events[9]?.type).toBe('turn/end') + + const repaired = await readFile(path) + expect(repaired.subarray(0, committed.length)).toEqual(committed) + expect(scanZstdFrames(repaired).tornStart).toBeUndefined() + expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events) + }) + + it('drops a frame torn in its header before it has produced plaintext', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('partial-magic') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const committed = await readFile(path) + await appendFile(path, MAGIC.subarray(0, 2)) + + expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) + expect(await readFile(path)).toEqual(committed) + }) + + it('recovers complete events when EOF tears only the final frame checksum', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('partial-checksum') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const secondTurn = [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + const frame = await compressZstdFrame(secondTurn.map(eventLine).join('\n') + '\n') + await appendFile(path, frame.subarray(0, -1)) + + const loaded = await ctx.sessionPersistence.load(header.id) + expect(loaded.events).toEqual([...oneTurnLog(), ...secondTurn]) + const repaired = await readFile(path) + expect(scanZstdFrames(repaired).tornStart).toBeUndefined() + expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events) + }) + + it('rejects a complete frame containing a torn JSONL record', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('complete-bad-jsonl') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await appendFile( + logPath(root, header.cwd, header.id, 'zstd'), + await compressZstdFrame('{"type":"turn/start"'), + ) + await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/complete frame contains a torn JSONL record/) + }) + + it('rolls back a checksummed append frame when fsync fails', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('zstd-fsync-rollback') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const path = logPath(root, header.cwd, header.id, 'zstd') + const before = await readFile(path) + + const handle = await open(path, 'r') + const prototype = Object.getPrototypeOf(handle) as { sync: () => Promise } + await handle.close() + const realSync = prototype.sync + let failed = false + const spy = vi.spyOn(prototype, 'sync').mockImplementation(async function (this: FileHandle) { + if (!failed) { + failed = true + throw new Error('simulated Zstandard fsync failure') + } + return realSync.call(this) + }) + const secondTurn = [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/) + expect(await readFile(path)).toEqual(before) + spy.mockRestore() + await ctx.sessionPersistence.append(header.id, secondTurn) + expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) + }) + + it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { + const root = await freshRoot() + const bucket = sessionDir(root, undefined) + await mkdir(bucket, { recursive: true }) + await writeFile(join(bucket, 'empty.jsonl.zstd'), '') + await writeFile(join(bucket, 'partial.jsonl.zstd'), MAGIC) + await writeFile(join(bucket, 'not-header.jsonl.zstd'), await compressZstdFrame('{"type":"turn/start"}\n')) + const ctx = await mount(root) + expect(await ctx.sessionPersistence.list()).toEqual([]) + + await writeFile(join(bucket, 'two-lines.jsonl.zstd'), await compressZstdFrame([ + JSON.stringify(toHeaderLine(meta('two-lines'))), + JSON.stringify({ type: 'turn/start' }), + '', + ].join('\n'))) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/) + await expect(ctx.sessionPersistence.load(SessionId('two-lines'))) + .rejects.toThrow(/first frame is not exactly one header line/) + }) + + it('rejects missing, empty, and checksum-corrupt header frames on targeted reads', async () => { + const root = await freshRoot() + const bucket = sessionDir(root, undefined) + await mkdir(bucket, { recursive: true }) + await writeFile(logPath(root, undefined, SessionId('partial-only'), 'zstd'), MAGIC) + await writeFile(logPath(root, undefined, SessionId('empty-header'), 'zstd'), await compressZstdFrame('')) + const corruptHeader = Buffer.from(await compressZstdFrame(`${JSON.stringify(toHeaderLine(meta('bad-checksum')))}\n`)) + corruptHeader[corruptHeader.length - 1] = corruptHeader[corruptHeader.length - 1]! ^ 0xFF + await writeFile(logPath(root, undefined, SessionId('bad-checksum'), 'zstd'), corruptHeader) + const ctx = await mount(root) + + await expect(ctx.sessionPersistence.load(SessionId('partial-only'))) + .rejects.toThrow(/empty or header-less Zstandard session log/) + await expect(ctx.sessionPersistence.load(SessionId('empty-header'))) + .rejects.toThrow(/first frame is not exactly one header line/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/) + }) +}) + +describe('SessionPersistenceJsonl: encoding selection', () => { + it('rejects roots owned by the opposite encoding in both directions', async () => { + const rawRoot = await freshRoot('dsh-jsonl-raw-mismatch-') + const raw = await mount(rawRoot, 'none') + const rawHeader = meta('raw-log') + await raw.sessionPersistence.create(rawHeader) + await raw.sessionPersistence.append(rawHeader.id, oneTurnLog()) + const defaultBackend = await mount(rawRoot) + await expect(defaultBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "zstd"/) + + const zstdRoot = await freshRoot('dsh-jsonl-zstd-mismatch-') + const zstd = await mount(zstdRoot) + const zstdHeader = meta('zstd-log') + await zstd.sessionPersistence.create(zstdHeader) + await zstd.sessionPersistence.append(zstdHeader.id, oneTurnLog()) + const rawBackend = await mount(zstdRoot, 'none') + await expect(rawBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "none"/) + }) + + it('rechecks targeted artifacts and listing after an initially empty root', async () => { + const root = await freshRoot() + const ctx = await mount(root) + expect(await ctx.sessionPersistence.list()).toEqual([]) + + const loadHeader = meta('late-raw-load', '/late') + await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true }) + await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [ + JSON.stringify(toHeaderLine(loadHeader)), + ...oneTurnLog().map(eventLine), + '', + ].join('\n')) + await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/) + await expect((ctx.sessionPersistence as SessionPersistenceJsonl).loadLive(loadHeader.id, loadHeader.cwd)) + .rejects.toThrow(/uses \.jsonl/) + await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/) + }) + + it('refuses materialization when an opposite artifact appears after create', async () => { + const root = await freshRoot() + const ctx = await mount(root) + await ctx.sessionPersistence.list() + const header = meta('late-raw-materialize', '/late') + await ctx.sessionPersistence.create(header) + await mkdir(sessionDir(root, header.cwd), { recursive: true }) + await writeFile(logPath(root, header.cwd, header.id, 'none'), [ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(eventLine), + '', + ].join('\n')) + await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/) + expect((await readdir(sessionDir(root, header.cwd))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false) + }) +}) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 2f55ebdfaa..20d441350b 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -52,5 +52,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path. +- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set `persistenceCompression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path. - **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 81e02da6a3..d4be5dc622 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -395,11 +395,11 @@ async function runStep( * header line, and return them ordered primary-first: the top-level session (no * `parentSession`) leads, then each subagent child by ascending `createdAt`. * - * The JSONL backend lays sessions out as `//.jsonl` - * (one bucket per cwd), so a parent and its same-cwd in-process child land in - * the SAME bucket — collecting all files across all buckets catches both (a - * first-match short-circuit would silently drop the child). Returns `[]` if no - * log was produced (a no-session scenario). + * Snapshot configs select the JSONL backend's raw mode, which lays sessions + * out as `//.jsonl` (one bucket per cwd). A + * parent and its same-cwd in-process child land in the SAME bucket, so + * collecting all files across all buckets catches both. Returns `[]` if no log + * was produced (a no-session scenario). */ async function harvestSessionLogs(root: string): Promise { let cwdDirs: string[] diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index 39a00856b7..751b7fc0bf 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -83,15 +83,12 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: assert request["authorization"] == "Bearer sdk-smoke-key" assert request["body"]["model"] == "sdk-smoke-model" - jsonl_files = sorted(session_root.rglob("*.jsonl")) - assert jsonl_files, f"no jsonl sessions were written under {session_root}" - print("session_jsonl_files:") + jsonl_files = sorted(session_root.rglob("*.jsonl.zstd")) + assert jsonl_files, f"no Zstandard JSONL sessions were written under {session_root}" + print("session_jsonl_zstd_files:") for path in jsonl_files: print(f" {path} bytes={path.stat().st_size}") - with path.open("r", encoding="utf-8") as handle: - first_line = handle.readline().strip() - if first_line: - print(f" first_line={first_line[:500]}") + assert path.read_bytes().startswith(bytes.fromhex("28b52ffd")) finally: server.shutdown() server.server_close() diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f46de4475a..1c7b3bbc8a 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -181,6 +181,11 @@ function gatesForMode(selected: Mode): Gate[] { 'run', 'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts', ], { label: 'source worker smoke' }), + pnpmExec('jsonl-zstd-smoke', [ + 'vitest', + 'run', + 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', + ], { label: 'JSONL Zstandard smoke' }), ] case 'pre-push': return [ @@ -379,7 +384,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate { for (const bucket of buckets) { if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue const entries = await readdir(join(sessionsRoot, bucket.name)) - if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) { + if (entries.some(entry => /^main-session-.+\.jsonl\.zstd$/.test(entry))) { found = true break } diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 6061d945e7..6d8890aa2e 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -64,6 +64,7 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -391,7 +392,7 @@ def smoke_sdk_default(base_url: str) -> None: result = harness.run("reply with the smoke text", session_id="default-smoke") assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response - assert_session_log(sessions, root, EXPECTED_TEXT) + assert_zstd_session_log(sessions) def smoke_sdk_custom(base_url: str, executable: Path) -> None: @@ -585,6 +586,14 @@ def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None: raise AssertionError(f"session log has no {expected!r} response: {logs[0]}") +def assert_zstd_session_log(sessions: Path) -> None: + logs = list(sessions.rglob("*.jsonl.zstd")) + if len(logs) != 1: + raise AssertionError(f"expected one Zstandard JSONL session log under {sessions}, found {logs}") + if not logs[0].read_bytes().startswith(bytes.fromhex("28b52ffd")): + raise AssertionError(f"session log has no Zstandard magic: {logs[0]}") + + def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: """Parse every persisted JSONL session into a map keyed by header id.""" logs: dict[str, list[dict[str, object]]] = {} From bec7ad544b443c33477f1df7d361b7e3078b5bb1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:06:39 +0800 Subject: [PATCH 044/132] docs(rfc): record Zstandard JSONL framing --- docs/architecture.md | 2 +- docs/core-data-structures/persistence.md | 2 +- docs/i18n/terminology.md | 1 + docs/rfc/INDEX.md | 1 + .../2026-06-14-session-persistence.md | 2 +- ...18-shared-persistence-write-coordinator.md | 2 +- ...-19-zstandard-jsonl-session-logs.i18n.yaml | 6 ++ ...2026-07-19-zstandard-jsonl-session-logs.md | 57 +++++++++++++++++++ ...6-07-19-zstandard-jsonl-session-logs.zh.md | 57 +++++++++++++++++++ .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- 10 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md create mode 100644 docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md diff --git a/docs/architecture.md b/docs/architecture.md index fd21648dd2..e1b60a5331 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ The session log is the source of truth. `deriveMessages()` projects session even **Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite. +Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract. ### Model Content diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 72ebcc5852..19fc547b04 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -95,7 +95,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi Both implement the same abstract `SessionPersistence` (locate/create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: -- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. +- **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. Multiple backends sharing one on-disk session coordinate writes through the [shared persistence write-coordinator](../rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f11a1ff19e..0037ba9cfa 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -64,6 +64,7 @@ | waterfall | waterfall | waterfall(瀑布式事件) | | | | wheel | wheel 包 | | | Python 打包格式 | | worktree | worktree | | | git 工作区概念 | +| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. | ## 双语类(中英文文本各自使用中英文) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 65afa473fc..564b0383ca 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -168,6 +168,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | +| [Zstandard JSONL session logs](implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) | 2026-07-19 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 3bd3c3fdf1..61f186805c 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -13,7 +13,7 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic: 1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. -2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**). +2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**), encoded as [checksummed Zstandard frames by default](2026-07-19-zstandard-jsonl-session-logs.md) or raw lines by configuration. Key choices recorded here because they are durable, contested, and surprising: diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index fef6ad034c..f69426dcac 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -28,7 +28,7 @@ Six methods (five required + an optional lifecycle hook) — the only seam betwe ### The opaque torn marker -The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is OPAQUE to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only ever tests `tornMarker !== undefined` and passes the value straight back to `commitRepair` — it never inspects it. Each backend picks its own marker type: JSONL uses the byte offset to truncate to, SQLite the seq to delete from (both happen to be `number`). The JSONL backend folds its `committedBytes < buffer.byteLength` comparison INSIDE the hook so the returned marker is already `number | undefined`; without that fold the coordinator would have to know about byte lengths. +The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is OPAQUE to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only ever tests `tornMarker !== undefined` and passes the value straight back to `commitRepair` — it never inspects it. Each backend picks its own marker type: JSONL carries the byte offset to truncate to plus any complete events decoded from an incomplete final frame, while SQLite carries the seq to delete from. The coordinator therefore knows neither byte lengths nor frame recovery state. ## Testing diff --git a/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml new file mode 100644 index 0000000000..35d0c29831 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-zstandard-jsonl-session-logs.md: 5c33b4a888e34d2f47ec5ede126c96a7b45f7fda +2026-07-19-zstandard-jsonl-session-logs.zh.md: 7dfa72e8e7064fde0fd5d7d026ab9b02e70287b7 diff --git a/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md new file mode 100644 index 0000000000..5c33b4a888 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md @@ -0,0 +1,57 @@ +# RFC: Zstandard JSONL session logs + +Status: implemented + +English | [中文](2026-07-19-zstandard-jsonl-session-logs.zh.md) + +## Problem + +The JSONL persistence backend keeps every `SessionEvent` verbatim, including high-volume `assistant/chunk` records. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties. + +The encoding also has to remain explicit at the deployment boundary. Snapshot fixtures and external line readers require raw JSONL, while a backend cannot safely guess between compressed and raw artifacts in one root or silently migrate pre-release session data. + +## Decision + +### Configuration and suffix ownership + +`dsh-session-persistence-jsonl` accepts `compression?: 'zstd' | 'none'` and explicitly resolves omission to `'zstd'`. Zstandard artifacts end in `.jsonl.zstd`; `'none'` retains the original newline-delimited UTF-8 `.jsonl` representation. `SessionLocation.kind` remains `'jsonl'`, because both encodings carry the same logical record format, and `SESSION_FORMAT_VERSION` remains `0` under the repository's pre-release reject-without-migration policy. + +Each persistence root belongs to one encoding. A one-time discovery preflight rejects any opposite suffix, and targeted load, live-adoption, listing, and materialization paths repeat the relevant suffix check after an initially empty preflight. The error names the incompatible artifact and directs the deployment to the matching configuration or a separate root. There is no migration, dual read, dual write, or extension-based fallback. + +### Frame and write path + +The compressed artifact is a standard concatenation of independent [Zstandard frames](https://datatracker.ietf.org/doc/html/rfc8878): one checksummed frame containing exactly the header line, followed by one checksummed frame for every durable append batch. Normal loop batches are turn commits, so frame boundaries preserve the existing persistence checkpoint without making the storage layer depend on turn event types. + +Compression uses Node's built-in [`zstdCompress` and `zstdDecompress`](https://nodejs.org/download/release/v22.19.0/docs/api/zlib.html), available at the repository's Node 22.19 floor. The backend enables `ZSTD_c_checksumFlag`, otherwise accepts Node's defaults, and exposes neither a compression-level knob nor a new dependency. The API is marked experimental by Node, so the Node 22.19, 24, and 26 compatibility gate exercises the exact helper. + +First materialization compresses the two initial frames before opening the temporary file, then keeps the existing write, file `fsync`, collision-safe hard-link publication, and directory `fsync` sequence. Later batches are compressed before opening the destination and appended at EOF. A caught write or file-sync failure truncates to the prior byte length, syncs the rollback, and rethrows so the coordinator can retry the unchanged batch. + +### Read, listing, and crash recovery + +A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially, which validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects. + +Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs. + +EOF inside the final frame is a recoverable torn tail. Node's decoder is given the available frame prefix; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames. + +### Consumers and verification + +The CLI, ACP, and stdio app bundles expose symmetric `persistenceCompression` pass-through configuration. Snapshot recording and replay compositions select `'none'` explicitly because committed fixtures are raw JSONL inputs to replay and normalization; ordinary runtime compositions use the compressed default. + +The shared persistence and coordinator contracts run against both encodings. Backend tests cover standard framing and checksum interoperability, header-only listing, append rollback, encoding mismatch rejection, complete-frame corruption, and final-frame tears through headers, blocks, and checksum trailers. Default runtime, built-bin, headless, ACP, and Python smokes assert the compressed suffix and Zstandard magic or decode the header; raw-content tests opt out explicitly. + +## Alternatives considered + +- **One frame per JSONL record** — rejected because it multiplies frame headers and checksums for high-volume chunk events and makes a physical boundary unrelated to the durable append batch. +- **Rewrite one whole compressed stream after every append** — rejected because cost grows with log size and replacement would give up append/fsync rollback and the established collision-safe materialization mechanics. +- **Use a streaming compressor across appends** — rejected because an interrupted encoder state does not leave independently checksummed append units, complicating bounded listing and frame-start repair. +- **Add an external native Zstandard dependency** — rejected because the supported Node floor already provides the required codec; another native artifact would enlarge installation and executable-packaging risk without adding a required behavior. +- **Expose compression level or keep raw JSONL as the default** — rejected because there is no deployment evidence for a second tuning policy, while `'none'` preserves the line-readable path for fixtures and integrations that need it. + +## Consequences + +- Ordinary session roots store `.jsonl.zstd` and retain append-only, fsync, rollback, and interrupted-turn recovery semantics. +- Raw JSONL remains a deliberate configuration, but changing encoding requires a fresh/separate root or selecting the mode that matches existing artifacts. +- One frame per durable batch adds bounded framing/checksum overhead and allows header-only listing plus repair from an exact append boundary. +- External tools must understand concatenated Zstandard frames or consume raw-mode artifacts; generic one-shot Node decompression reads only the first independent frame, so backend reads walk frames explicitly. +- The implementation depends on Node's experimental built-in Zstandard API without an npm dependency; the supported-version compatibility gate makes drift visible. diff --git a/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md new file mode 100644 index 0000000000..7dfa72e8e7 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md @@ -0,0 +1,57 @@ +# RFC:Zstandard JSONL 会话日志 + +Status: implemented + +[English](2026-07-19-zstandard-jsonl-session-logs.md) | 中文 + +## 问题 + +JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量庞大的 `assistant/chunk` 记录。原始文本便于检查,但重复的 JSON 键和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有的 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复以及仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。 + +编码还必须在部署边界上保持显式。快照 fixture 与外部逐行读取器需要原始 JSONL,而后端无法在同一根目录中安全猜测压缩产物与原始产物,也不能静默迁移预发布会话数据。 + +## 决策 + +### 配置与后缀归属 + +`dsh-session-persistence-jsonl` 接受 `compression?: 'zstd' | 'none'`,并将省略值显式解析为 `'zstd'`。Zstandard 产物使用 `.jsonl.zstd` 后缀;`'none'` 保留原有的换行分隔 UTF-8 `.jsonl` 表示。`SessionLocation.kind` 仍为 `'jsonl'`,因为两种编码承载同一逻辑记录格式;按照仓库的预发布拒绝且不迁移策略,`SESSION_FORMAT_VERSION` 仍为 `0`。 + +每个持久化根目录只归属于一种编码。一次性的发现预检会拒绝任何相反后缀,而针对性的加载、活跃采用、列举与物化路径会在最初空目录预检之后再次执行对应后缀检查。错误会指出不兼容产物,并要求部署选择匹配配置或单独根目录。系统不提供迁移、双重读取、双重写入或基于扩展名的兜底。 + +### 帧与写入路径 + +压缩产物是标准独立 [Zstandard 帧](https://datatracker.ietf.org/doc/html/rfc8878)的串联:第一个带校验和的帧只包含头部行,后续每个持久追加批次各占一个带校验和的帧。正常 agent loop 批次就是轮次提交,因此帧边界保留既有持久化检查点,同时不让存储层依赖轮次事件类型。 + +压缩使用 Node 内置的 [`zstdCompress` 与 `zstdDecompress`](https://nodejs.org/download/release/v22.19.0/docs/api/zlib.html),仓库最低支持的 Node 22.19 已提供这些 API。后端启用 `ZSTD_c_checksumFlag`,其余采用 Node 默认值,不公开压缩级别调节项,也不增加依赖。Node 将该 API 标记为实验性,因此 Node 22.19、24 与 26 兼容性门禁会执行同一个辅助实现。 + +首次物化会在打开临时文件之前压缩两个初始帧,然后保留既有的写入、文件 `fsync`、避免冲突的硬链接发布与目录 `fsync` 顺序。后续批次也会先压缩,再打开目标并在 EOF 追加。捕获到写入或文件同步失败时,后端会截断到原有字节长度,同步回滚结果,再重新抛出错误,让协调器重试未变化的批次。 + +### 读取、列举与崩溃恢复 + +帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端独立且按顺序解压完整帧,由此验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。 + +列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。 + +最终帧内部遇到 EOF 属于可恢复的撕裂尾部。后端把已有帧前缀交给 Node 解码器,并保留其产出的每个完整、以换行结束的事件。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。 + +### 消费方与验证 + +CLI、ACP 与 stdio 应用包公开对称的 `persistenceCompression` 透传配置。快照录制与回放组合显式选择 `'none'`,因为提交的 fixture 是回放与规范化过程使用的原始 JSONL 输入;普通运行时组合使用压缩默认值。 + +共享持久化契约与协调器契约会针对两种编码运行。后端测试覆盖标准帧与校验和互操作性、仅头部列举、追加回滚、编码不匹配拒绝、完整帧损坏,以及横跨头部、块和校验和尾部的最终帧撕裂。默认运行时、构建后二进制、headless、ACP 与 Python 冒烟测试会断言压缩后缀与 Zstandard 魔数,或解码头部;读取原始内容的测试则显式退出压缩。 + +## 考虑过的替代方案 + +- **每条 JSONL 记录一个帧**——不予采纳,因为它会让大量分片事件各自承担帧头与校验和开销,并让物理边界脱离持久追加批次。 +- **每次追加都重写一个完整压缩流**——不予采纳,因为成本会随日志大小增长,而且替换操作会放弃追加/fsync 回滚和既有的无冲突物化机制。 +- **跨追加使用流式压缩器**——不予采纳,因为编码器状态中断后不会留下可独立校验的追加单元,从而使有界列举与按帧起点修复更复杂。 +- **增加外部原生 Zstandard 依赖**——不予采纳,因为受支持的 Node 最低版本已经提供所需编解码器;另一个原生产物会增加安装与可执行文件打包风险,却不增加必需行为。 +- **公开压缩级别或继续默认使用原始 JSONL**——不予采纳,因为没有部署证据支持第二种调节策略,而 `'none'` 已为需要逐行读取的 fixture 与集成保留路径。 + +## 后果 + +- 普通会话根目录存储 `.jsonl.zstd`,并保留仅追加、fsync、回滚与中断轮次恢复语义。 +- 原始 JSONL 仍是显式配置,但切换编码需要使用全新或单独根目录,或者选择与既有产物匹配的模式。 +- 每个持久批次一个帧会增加有界的帧与校验和开销,同时支持仅头部列举和从精确追加边界开始修复。 +- 外部工具必须理解串联的 Zstandard 帧,或者消费原始模式产物;Node 通用的一次性解压只读取第一个独立帧,因此后端读取会显式遍历各帧。 +- 实现依赖 Node 的实验性内置 Zstandard API,但不增加 NPM 依赖;受支持版本兼容性门禁会暴露 API 漂移。 diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 2f7c0dbd8e..697d916a99 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -40,7 +40,7 @@ Replay is positional and therefore permits only one in-flight model stream per s ### Recording harvests the log; keyless replay needs a providerless config -Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. +Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend configured with `persistenceCompression: 'none'`, then copies the produced `.jsonl` into the scenario dir. The explicit raw mode keeps committed replay fixtures line-readable while ordinary deployments use the backend's compressed default. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with `llm-replay` while retaining the live composition. Recording uses the ordinary config and a harness-supplied persistence root. Replay mode skips `.env` loading, so a stray API key cannot trigger a live call. See the [single-source config RFC](2026-07-04-single-source-acp-replay-config.md). From 277b58088f68b10cac0b9b6b1010f2ea7dfe7a0b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:28:51 +0800 Subject: [PATCH 045/132] docs: narrow LLM request recovery proposal --- ...2026-06-21-bounded-llm-request-recovery.md | 147 ++++++++++++ .../2026-06-21-unstable-llm-api-recovery.md | 219 ------------------ 2 files changed, 147 insertions(+), 219 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md delete mode 100644 .agents/notes/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md diff --git a/.agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md new file mode 100644 index 0000000000..e57cffe687 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -0,0 +1,147 @@ +# Agent Note: Bounded recovery for transient LLM request failures + +Status: proposed + +## Problem + +`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. + +That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. + +Three narrower gaps remain. + +- Provider failures retain only a message and usually a code. HTTP status, retry delay, and provider request id are discarded or recoverable only through provider-specific error objects, so generic recovery cannot make or explain a decision without parsing text. +- Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with a future `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. +- A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. + +The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. + +## Proposal + +### Preserve failure facts without embedding policy + +Add one JSON-serializable `LlmFailure` payload to `@deepseek-ai/dsh-llm`: + +```ts ignore-check +type ProviderRequestId = Branded<'ProviderRequestId'> + +interface LlmFailure { + message: string + code: string + status?: number + retryAfterMs?: number + requestId?: ProviderRequestId +} +``` + +`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events. + +`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. An adapter-thrown `Error` keeps its exact object identity: the final-adapter scope associates the normalized facts with that object in call-local sidecar state and rethrows it unchanged; a non-`Error` throw is wrapped as today. `llmFailureOf(stream, error)` retrieves those facts alongside the existing provenance check, while an in-band finish without an error object becomes a new `LlmError`. This preserves listeners that key on error type or identity while giving all final-adapter failures, including unknown SDK exceptions, an `UNKNOWN` terminal payload. + +The agent loop keeps `RequestError` as that exact error object and passes `LlmFailure` as a separate argument to `agent/request-error`; it does not mutate possibly frozen third-party errors. It also uses the payload when converting an in-band finish and when recording an unrecovered `turn/end.reason`. + +Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. + +The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. + +### Put retry policy on the existing failed-step seam + +Add a function plugin, `@deepseek-ai/dsh-llm-retry`, that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. + +Replace the scalar `retryAttempt` argument with the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history as it clears the current scalar. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. + +The plugin resolves and validates this deployment configuration at load: + +```ts ignore-check +interface Config { + maxTransientRetries?: number + initialDelayMs?: number + maxDelayMs?: number + jitterRatio?: number + retryableCodes?: string[] +} +``` + +The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. + +For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid provider `retryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. + +The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. + +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. + +The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. + +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. + +### Make one layer own visible attempts + +Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`. + +`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk, but this proposal does not add one speculatively. + +### Bound stalled streams where they can be stopped + +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. + +Extend `@deepseek-ai/dsh-timeout` with a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. + +The two adapters must prove termination at their actual boundaries. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK only after a test proves the SDK stops the request. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. + +### Keep attempts separate in the existing log + +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. + +If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. + +## Out of scope + +- Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider. +- Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message. +- Repairing malformed tool arguments, refusals, content filters, or other semantic model output. +- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets. +- Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer. + +## Alternatives considered + +- **Retry inside `llm/stream` or the provider SDK** — rejected because a raw stream has no durable attempt boundary after emitting chunks, hidden SDK retries multiply budgets, and neither path can record each failed attempt consistently. +- **Add response start, interrupted, discarded, failed, and committed events to `dsh-llm`** — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry. +- **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility. +- **Put `retryable` or `failover` on `LlmFailure`** — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch. +- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe. +- **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. +- **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. + +## Acceptance criteria + +- `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available. +- An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. +- DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. +- Pi-ai performs one wire attempt per adapter call, and a wire-level test rejects any regression that silently restores SDK retries. +- `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. +- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. +- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. +- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. +- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and has a production UI consumer with keyless ACP or TUI snapshot coverage for scheduled retry, cancellation during delay, and eventual success or exhaustion. +- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. +- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. +- The architecture LLM section, the implemented request-recovery and timeout notes, agent-loop and adapter READMEs, package catalogs, example configuration, persistence catalog, and testing documentation are updated in the implementation change; all generated outputs and bilingual counterparts required by those files are refreshed together. + +## Risks + +- A retry can duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Provider SDKs may hide status or retry headers. Those adapters must use `UNKNOWN` or a stable coarse code rather than infer policy from fragile text. +- Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. +- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots must make the transition explicit. +- Adapter-local timeout enforcement can drift across transport libraries. Contract tests at the termination boundary are required for both implementations. +- Multiple recovery plugins add their finite budgets. Their classifiers should remain disjoint; an overlapping classifier is registration-order policy and must be documented and tested by the plugins that introduce it. + +## Related + +- [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md) owns stable machine-routable codes and cause chaining. +- [Reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) makes provider/model and complete request inputs durable before dispatch. +- [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination. +- [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery seam and bounded overflow retry. +- [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant. diff --git a/.agents/notes/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md b/.agents/notes/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md deleted file mode 100644 index 9e5ad308be..0000000000 --- a/.agents/notes/proposed/architecture/2026-06-21-unstable-llm-api-recovery.md +++ /dev/null @@ -1,219 +0,0 @@ -# Agent Note: Treat unstable LLM APIs as a first-class failure mode - -Status: proposed - -## Problem - -LLM APIs are not a stable local function call. They rate-limit, overload, return 5xx/502/503 from gateways, close streaming sockets before `[DONE]`, emit malformed or provider-specific error payloads, hang mid-stream, surface SDK errors as in-band events, and sometimes require a delayed retry using `Retry-After`. The harness currently contains good error containment, but it does not yet treat this API instability as a first-class design problem. - -`dsh-llm` defines two sanctioned adapter failure paths - throw from `stream()` or end with `finish { kind: 'error' | 'aborted' }` - and downstream consumers are expected to treat both as failed model calls. That was the right MVP containment baseline, documented in [the architecture](../../../../docs/architecture.md) and reinforced by [the twin-adapter RFC](../../implemented/architecture/2026-06-13-twin-llm-adapters.md). It is not enough for callers that depend on an unstable remote model API for every turn, and it forces every caller to understand two failure delivery mechanisms. - -The audit found three load-bearing gaps. - -- `LlmError` and `FinishReasonMap.error` carry only `message`, `code`, and sometimes HTTP `status` ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts), [packages/llm/llm/src/types.ts](../../../../packages/llm/llm/src/types.ts)). A caller cannot reliably tell "retry this after 800 ms on the same endpoint", "fail over to another route for the same model", "ask the user for credentials", "never retry because the request is invalid", "provider truncated the stream after committed output", or "adapter protocol bug" without provider-specific heuristics. -- The `llm/stream` waterfall is documented as the place for retry/routing/caching, but its value is one raw `AsyncIterable`. A listener can technically catch an API error and call `next()` again, but once it has yielded chunks, callers may already have rendered them, buffered them as output, or executed side effects based on completed tool calls. Retrying after that point can concatenate chunks from two provider responses into one apparent model output. The current surface has no canonical way to say "the tokens you saw were tentative; this response timed out, so discard them and restart." -- The adapter registry is one adapter per model name. That makes "logical model" and "concrete API route" the same thing, so the service has no vocabulary for "same model through another endpoint or SDK", "same provider region with different health", "fallback route with compatible capabilities", provider request ids, or per-route backoff state. - -The result is a package that can surface an unstable LLM API failure, but cannot make principled recovery decisions for its callers. Ordinary provider turbulence should not require every consumer to reinvent retries around an unsafe stream boundary. - -## Proposal - -Introduce an LLM-call v2 contract centered on API-instability recovery: classify provider/API failures, separate provider responses from committed model output, route logical models through recoverable API routes, and make conservative retry/failover the default behavior in `dsh-llm`. Because the harness is unreleased, this should be a breaking cleanup rather than a compatibility layer around the underspecified v1 surface. - -### 1. Replace flat error codes with a serializable `LlmFailure` - -Keep `HarnessError` as the common thrown-error base, but make LLM failures carry a structured, JSON-serializable payload. `code` remains a stable leaf label for logs and provider-specific matching; retry/failover policy branches on the structured fields. - -```ts -type LlmFailureClass = - | 'auth' - | 'rate-limit' - | 'quota' - | 'invalid-request' - | 'unsupported' - | 'timeout' - | 'transport' - | 'provider-overloaded' - | 'provider-unavailable' - | 'provider-bug' - | 'protocol' - | 'safety' - | 'aborted' - | 'unknown' - -type LlmFailurePhase = - | 'request-build' - | 'connect' - | 'response-headers' - | 'stream' - | 'finish' - -interface LlmFailure { - message: string - code: string - class: LlmFailureClass - phase: LlmFailurePhase - retryable: boolean - failover: 'never' | 'same-model' | 'compatible-model' - partialOutput: 'none' | 'uncommitted' | 'committed' - provider?: string - routeId?: string - model?: string - wireModel?: string - status?: number - retryAfterMs?: number - requestId?: string -} -``` - -`LlmError` should carry `failure: LlmFailure`; `FinishReasonMap.error` should carry the same payload instead of a parallel `{ message, code? }` shape. Adapter-thrown errors and in-band finish errors are input forms to the recovery layer. The public lifecycle stream should convert classified LLM API failures into terminal lifecycle events rather than requiring callers to catch thrown provider errors. The failure payload is serializable so callers can log or persist it if they choose, while the internal thrown `LlmError` may still carry a non-serializable `cause` chain for local debugging. - -Adapters are responsible for faithful provider/API classification at their boundary: HTTP status, `Retry-After`, provider request id headers, SDK error type, timeout vs caller abort, malformed SSE, missing `[DONE]`, unknown finish reason, and unsupported local request shape. The current pi-ai adapter's regex over message text is acceptable only as a temporary fallback when the SDK hides the real status; the adapter should prefer structured SDK/provider fields when available. - -### 2. Split provider responses from committed model output - -Make "response" a first-class boundary in `ctx.llm.stream()`. An adapter streams one provider API response. The LLM service runs zero or more responses according to recovery policy and exposes one canonical response-lifecycle stream to consumers. Convenience APIs may expose a committed-or-failed result for simple callers, but that result must be derived from the lifecycle stream, not a parallel contract. - -The primary response id is generated by the harness before the adapter call starts. Provider response ids and request ids are metadata attached when known; they are not the primary key because providers may omit them, report them only after the stream starts, reuse them in surprising ways, or fail before one exists. - -The important invariant: chunks from a failed response must never be silently spliced together with chunks from a later response as one apparent model result. Token deltas from a response are tentative until that response reaches a committing terminal finish (`stop`, `tool-calls`, or `max-tokens`). The lifecycle stream must be able to report that tentative tokens were shown live, then discarded because the response timed out, disconnected, or otherwise failed before commit. - -The event vocabulary should keep the familiar `assistant/chunk` concept but stop pretending every chunk is already final output. A possible spelling is: - -```ts ignore-check -type LlmStreamEvent = - | { type: 'response/start'; responseId: ResponseId; responseIndex: number; routeId: string } - | { type: 'assistant/chunk'; responseId: ResponseId; commitment: 'uncommitted'; chunk: StreamChunk } - | { type: 'response/interrupted'; responseId: ResponseId; failure: LlmFailure; scheduledRetryMs?: number } - | { type: 'response/failed'; responseId?: ResponseId; failure: LlmFailure } - | { type: 'response/committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } - -type LlmCallOutcome = - | { type: 'committed'; responseId: ResponseId; message: Message; finish: FinishReason; usage?: TokenUsage } - | { type: 'failed'; responseId?: ResponseId; failure: LlmFailure } -``` - -The implementation may choose the exact names, but the type shape should make the state transition obvious: assistant chunks start uncommitted, then the enclosing response becomes interrupted/discarded, failed, or committed. - -- **Lifecycle assistant chunks.** The lifecycle stream should make the old ambiguity explicit: these events are assistant chunk messages, but each one belongs to a response and has a commitment state. Most arrive as uncommitted live UI state; a response that fails before commit marks them interrupted/discarded, and a response that reaches a committing finish lets the UI mark that response committed. -- **Retry before commit.** If an API response fails before a committing finish, the service may retry or fail over and exclude the failed response from the committed result, while surfacing response diagnostics separately. -- **Commit on terminal finish.** Once a response reaches a committing finish, the response owns the visible result. `dsh-llm` emits a `response/committed` event carrying the fully assembled assistant `Message`, final `FinishReason`, usage, and response metadata. Callers that persist messages, execute tool calls, or otherwise take side effects should use this committed event rather than rebuilding output from lifecycle chunks. -- **Terminal failure.** If recovery reaches a non-retryable failure, is aborted by the caller, or otherwise stops without a committing finish, `dsh-llm` emits `response/failed` carrying the final `LlmFailure` and ends the lifecycle stream normally. Throwing is reserved for defects outside the classified LLM API failure contract. -- **Fail after commit.** If a later failure is ever observable after commit, the lifecycle reports `response/failed` with `partialOutput: 'committed'`; automatic retry is not allowed unless a later RFC designs an explicit continuation/repair protocol. - -This means replacing the single overloaded raw-chunk `llm/stream` waterfall with a lifecycle stream and narrower hooks: one around a single provider response, one around recovery policy decisions, and one around convenience APIs that only expose the terminal outcome. Names are implementation details for the follow-up PR, but the semantics are not: plugins must be able to wrap "one API response" without pretending they can safely retry already-committed chunks. - -### 3. Route logical models through recoverable API routes - -Separate the logical model a caller requests from the concrete provider route that serves a response. Replace "one adapter per model name" with route registration, for example: - -```ts ignore-check -ctx.llm.registerRoute({ - routeId: 'deepseek-direct:deepseek-v4-flash', - model: 'deepseek-v4-flash', - wireModel: 'deepseek-v4-flash', - provider: 'deepseek', - adapter, - priority: 0, - capabilities: { tools: true, reasoning: true, images: false, prefill: false }, -}) -``` - -`GenerateOptions.model` remains the logical model. The service resolves it to a route for each API response, records the route in failure/response diagnostics, and can retry on the same route or fail over to another route with compatible capabilities. Duplicate model names become normal; duplicate route ids are the conflict. This is the smallest vocabulary that can express direct endpoint vs SDK-backed endpoint, regional endpoints, and future fallback models without making every caller own routing. - -The route registry must keep the lifecycle guarantees of the current adapter registry: `registerRoute()` is effect-scoped, returns a disposer, and has an HMR-safety test proving disposal removes the route. It should not preserve `llm/adapter-change`; if [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82) lands first, that event is already gone, and the route registry should not reintroduce it without a concrete consumer. - -The new ids should follow the branded-id policy. `ResponseId`, `RouteId`, and the logical/wire model ids cross package boundaries and are easy to swap accidentally, so the implementation should deliberately brand or explicitly decline to brand each one in line with `2026-06-20-branded-ids` and its implementation stack ([PR #84](https://github.com/deepseek-ai/deepseek-harness/pull/84)). - -### 4. Put default API recovery policy in `dsh-llm` - -Adapters should not perform hidden SDK retries unless those retries are surfaced as response lifecycle events with classified failures. The service owns the default policy so every consumer gets the same behavior and the same audit trail. - -Default policy should be conservative: - -- Retry transient API failures (`rate-limit`, `timeout`, `transport`, `provider-overloaded`, `provider-unavailable`) only before committed output, and keep retrying until the caller aborts or the failure class changes to a non-retryable one. -- Honor `retryAfterMs` up to `maxRetryDelayMs`, otherwise use bounded exponential backoff with jitter. The same cap applies to both provider-supplied retry hints and ordinary exponential backoff; diagnostics record whether the delay source was `provider-retry-after` or `exponential-backoff`. -- Treat 429/408/409/425/500/502/503/504 and connection resets as potentially recoverable unless the provider payload says otherwise; treat 400/401/403, unsupported local options, caller abort, and adapter protocol bugs as non-retryable. -- Fail over only when the failure says failover is safe and the candidate route advertises compatible capabilities for the request (`tools`, reasoning passback, images, prefill, stop sequences, strict tools). -- Share the caller's `AbortSignal` across the whole recovered call, and expose per-response timeouts as explicit policy. A stuck stream must time out in a controlled way instead of hanging the turn forever. -- Surface every retry decision to the UI with retry count, backoff delay, route, and failure summary, so an actively watching user can tell the agent is waiting on provider capacity instead of frozen. - -The policy should be configurable through a typed service option and an event/waterfall seam so product plugins can adjust timing/backoff details, but the default retry posture is not opt-in: a basic agent should keep recovering from retryable 429/5xx/connectivity noise until cancelled. - -The zero-config defaults should be sensible production behavior, not placeholders: - -```ts -const defaultLlmRecoveryConfig = { - maxResponses: 'unbounded', - maxElapsedMs: 'unbounded', - connectTimeoutMs: 15_000, - responseHeaderTimeoutMs: 60_000, - streamIdleTimeoutMs: 5 * 60_000, - initialBackoffMs: 200, - maxRetryDelayMs: 10 * 60_000, - jitterRatio: 0.1, -} -``` - -The retry-delay cap is deliberate. The survey found mixed precedent: Codex parses retry delays out of streamed OpenAI rate-limit error messages and uses that requested delay, but Codex also has finite stream retry counts; the official OpenAI and Anthropic TypeScript SDKs parse `retry-after-ms`, `Retry-After` seconds, and `Retry-After` dates and then sleep for the provider-specified duration; the official OpenAI and Anthropic Python SDKs only honor `Retry-After` when it is greater than zero and at most 60 seconds, otherwise falling back to ordinary exponential backoff. Because this RFC's default retry posture is unbounded, blindly honoring a multi-hour provider delay can make the agent look dead, while ignoring the hint entirely can retry too aggressively. The service should therefore record both `providerRetryAfterMs` and `scheduledRetryMs`, cap the scheduled sleep at `maxRetryDelayMs`, and surface that choice to the UI. - -The service cannot reliably infer whether the user is actively watching or away from the keyboard, so the default should not fail a retryable model call merely because a short interactive budget expired. A clear UI can make long waits tolerable even in interactive sessions: "retried 8 times; next retry in 10 minutes" is better than silently failing recoverable provider turbulence and asking the user to resubmit. - -### 5. Define the caller contract, not the product transcript - -This RFC is deliberately about the `dsh-llm` API and how callers use it, not about the final transcript/event architecture of the product. The LLM package should guarantee these caller-visible semantics: - -- `ctx.llm.stream()` is the live response-lifecycle API. It reports response starts, uncommitted assistant chunks, retries/backoff, interruptions/discards, terminal failures, and the one committed result. -- `response/committed` is the only event that makes model output safe for history or side effects. It carries the assembled `Message`, finish reason, usage, response id, route metadata, and provider ids known to the service. -- `response/failed` is the terminal event for classified failures. Callers should not need `try`/`catch` to learn that a provider was rate-limited, unavailable, misconfigured, aborted, or otherwise unable to produce a committed response. -- Response diagnostics are JSON-serializable so callers can store, display, or ignore them. The LLM package does not decide whether those diagnostics become session events, agent events, telemetry rows, or UI-only state. -- Any assembled convenience helper that survives or is reintroduced returns a terminal union (`committed` or `failed`) rather than throwing for classified LLM failures. It must be derived from the lifecycle stream so recovery semantics stay single-sourced. - -The session log shape, agent event taxonomy, ACP rendering, snapshot/replay fixtures, and whether live uncommitted chunks are ever durably recorded are downstream integration decisions. So is the fate of today's assembled public helper methods: [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82) implements the proposed removal of `generate()`, `streamBlocks()`, `GenerateResult`, and `llm/generate`, and this RFC should not resurrect them without a real caller. This RFC should constrain downstream work only by the LLM API contract above. - -The current simplification stack was checked while drafting this proposal. [PR #83](https://github.com/deepseek-ai/deepseek-harness/pull/83) and [PR #85](https://github.com/deepseek-ai/deepseek-harness/pull/85) do not change this RFC's LLM API assumptions. [PR #86](https://github.com/deepseek-ai/deepseek-harness/pull/86) does matter for later integration because it folds durable token usage onto `assistant/message` and operational errors onto `turn/end.reason`; if it lands first, the LLM recovery implementation should still stop at the `dsh-llm` lifecycle contract here and let the agent/session layer decide how committed usage and terminal failures map onto those load-bearing product events. - -## Out of scope - -This RFC does not propose silent mid-stream continuation after user-visible output. That requires a separate model-history design: either provider-supported prefill/continuation, a recovery prompt that explicitly shows the partial assistant output, or a UI affordance that marks the partial answer as failed and asks the model to continue in a new step. Splicing two API responses into one assistant message is rejected. - -This RFC also does not solve semantic model-output repair: malformed tool-call JSON, refusal handling, or content-filter fallbacks. Those may use the same failure vocabulary later, but they are higher-level agent behaviors, not unstable-API recovery. - -This RFC does not decide whether product UIs consume LLM lifecycle events directly, through agent events, or through session events. It also does not decide which response diagnostics belong in the durable session log. Those decisions belong in narrower integration RFCs once the `dsh-llm` contract exists. - -## Alternatives considered - - - -## Acceptance criteria - -- Adapter-thrown `LlmError`s and in-band finish errors carry one structured, JSON-serializable `LlmFailure` payload. -- Recovery policy can distinguish retry, failover, credential/user-action, unsupported request, caller abort, adapter/protocol bug, and post-commit partial stream failure without parsing message text. -- `ctx.llm.stream()` exposes the response lifecycle as the canonical stream, including terminal `response/failed` events for classified failures; convenience APIs are derived views for callers that only want the terminal outcome. -- `response/committed` carries the assembled assistant `Message`; callers do not need to rebuild committed output from lifecycle chunks. -- Any assembled convenience API that survives or is reintroduced returns a committed/failed union derived from the lifecycle stream, rather than throwing for classified LLM failures. -- The LLM service has an explicit API-response boundary; no retry path can present output from two provider responses as one committed assistant result. -- The route registry allows multiple concrete API routes for one logical model and records the selected route on responses/failures. -- `registerRoute()` is effect-scoped, returns a disposer, and has an HMR-safety test proving route cleanup; `llm/adapter-change` is not reintroduced unless a concrete consumer needs it. -- New LLM ids are deliberately branded or explicitly left unbranded according to the branded-id policy, with `ResponseId`, `RouteId`, and logical/wire model ids decided together. -- Default recovery retries transient pre-commit failures with bounded backoff, honors provider retry-after hints, times out stuck streams, disables hidden SDK retries or surfaces them as response lifecycle events, and never retries after committed chunks without an explicit continuation design. -- Unit tests cover thrown errors and finish-error chunks through `dsh-llm`, retry-before-first-commit, failover to a second compatible route, unbounded retry status/backoff visibility, abort during backoff, stream timeout, and the "partial chunks then failure does not retry/splice" invariant. -- Adapter tests classify representative HTTP statuses, retry-after headers, request ids, malformed/truncated SSE streams, SDK in-stream errors, caller aborts, and unsupported options into `LlmFailure`. -- Docs updated in the same change: [the architecture LLM section](../../../../docs/architecture.md), [the LLM adapter cookbook](../../../../docs/cookbook/adding-an-llm-adapter.md), and the LLM package README. - -## Risks - -- **More surface area in the LLM core.** API recovery adds policy, route state, response diagnostics, and tests. That complexity belongs in `dsh-llm` because every consumer otherwise reinvents it around the same unsafe stream boundary. -- **Committed result lags live UI.** Safe recovery means callers cannot treat streamed tokens as final assistant output until the response commits. UIs can still stream eagerly from lifecycle assistant chunks, but side-effecting consumers must wait for `response/committed`. -- **Breaking adapter churn.** Existing adapters will change from "stream chunks or throw a flat `LlmError`" to "stream one classified API response." Pre-release rules favor the correct seam over shims. -- **Route compatibility is easy to overclaim.** A route must advertise concrete capabilities, and failover must check the request actually fits them. "Same model name" is not enough when one route lacks strict tools, reasoning passback, images, stop sequences, or prefill. - -## Related - -- Builds on [Provider-neutral content-block vocabulary](../../implemented/architecture/2026-06-11-content-block-vocabulary.md): the content vocabulary stays provider-neutral; this adds a provider-neutral failure/recovery vocabulary beside it. -- Revises the scope implied by [Two LLM adapters as a design-verification twin](../../implemented/architecture/2026-06-13-twin-llm-adapters.md): the twin validated chunk shape and error delivery paths, but it also exposed that delivery paths are not enough for unstable API recovery. -- Extends [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md): `HarnessError.code` was the foundation; LLM API recovery needs a richer payload because retry/failover policy cannot safely branch on one flat string. -- Coordinates with [PR #82](https://github.com/deepseek-ai/deepseek-harness/pull/82), which implements the `drop-unconsumed-llm-assembled-surfaces` and `drop-unconsumed-llm-adapter-change-event` simplification RFCs. If that PR lands first, this RFC starts from a narrower `dsh-llm`: no `generate()`, no `streamBlocks()`, no `GenerateResult`, no `llm/generate`, and no `llm/adapter-change`. Recovery should build on that baseline rather than revive removed convenience or change-notification surfaces speculatively. -- Is orthogonal to [PR #81](https://github.com/deepseek-ai/deepseek-harness/pull/81), which proposes provider-request app attribution headers. Recovery route metadata and provider request construction can carry attribution policy later, but this RFC does not define request headers. -- Coordinates with [PR #84](https://github.com/deepseek-ai/deepseek-harness/pull/84), which implements the branded-id RFC. The new response/route/model ids introduced here are exactly the sort of cross-boundary ids that need an explicit branding decision before implementation. -- Coordinates with [PR #86](https://github.com/deepseek-ai/deepseek-harness/pull/86), which implements the `collapse-trace-only-session-events` simplification RFC. If that stack lands first, downstream recovery integration should map committed usage and terminal error facts onto the surviving load-bearing session events instead of reintroducing standalone trace-only records from inside `dsh-llm`. From b8080268599b33090c150786feb0b9438517f716 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:51:19 +0800 Subject: [PATCH 046/132] feat(workflow): add fresh-agent Ralph tool --- docs/capability-seams.md | 7 +- docs/config-catalog.md | 18 + docs/core-data-structures/workflow.md | 8 +- docs/glossary.md | 6 + docs/module-graph.md | 8 + docs/rfc/INDEX.md | 1 + ...-fresh-agent-ralph-workflow-tool.i18n.yaml | 6 + ...6-07-19-fresh-agent-ralph-workflow-tool.md | 67 +++ ...7-19-fresh-agent-ralph-workflow-tool.zh.md | 67 +++ docs/tool-catalog.md | 30 ++ examples/acp-agent/composition.md | 3 + examples/acp-agent/cordis.yml | 3 + .../system-prompt.expected.md | 9 + .../tool-schemas.expected.json | 20 + .../both-mode-turn/system-prompt.expected.md | 9 + .../both-mode-turn/tool-schemas.expected.json | 20 + .../code-mode-turn/system-prompt.expected.md | 9 + .../system-prompt.expected.md | 9 + .../model-switching/system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 40 ++ .../system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 40 ++ .../skill-load/system-prompt.expected.md | 2 + .../skill-load/tool-schemas.expected.json | 20 + .../text-turn/system-prompt.expected.md | 2 + .../text-turn/tool-schemas.expected.json | 20 + .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 20 + .../workspace-edit/system-prompt.expected.md | 2 + .../workspace-edit/tool-schemas.expected.json | 20 + examples/headless-agent/README.md | 2 +- examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 5 + examples/package.json | 1 + examples/repl-agent/README.md | 4 +- examples/repl-agent/composition.md | 3 + examples/repl-agent/cordis.yml | 3 + examples/tui-agent/tests/tui.snapshot.ts | 2 + packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/workflow/README.md | 3 +- packages/workflow/tool-ralph/README.md | 87 ++++ packages/workflow/tool-ralph/package.json | 53 +++ packages/workflow/tool-ralph/src/index.ts | 384 ++++++++++++++++++ .../tool-ralph/tests/integration.spec.ts | 92 +++++ .../tool-ralph/tests/tool-ralph.spec.ts | 319 +++++++++++++++ packages/workflow/tool-ralph/tsconfig.json | 39 ++ .../workflow/workflow-workerthread/README.md | 4 +- .../workflow-workerthread/src/index.ts | 3 +- .../tests/built-worker.e2e.ts | 22 +- .../tests/workflow-workerthread.spec.ts | 20 + packages/workflow/workflow/README.md | 2 +- packages/workflow/workflow/src/types.ts | 6 + pnpm-lock.yaml | 55 +++ scripts/gen-doc-graphs.ts | 8 +- scripts/gen-tool-catalog.ts | 18 +- tsconfig.json | 1 + 58 files changed, 1599 insertions(+), 22 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md create mode 100644 docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md create mode 100644 packages/workflow/tool-ralph/README.md create mode 100644 packages/workflow/tool-ralph/package.json create mode 100644 packages/workflow/tool-ralph/src/index.ts create mode 100644 packages/workflow/tool-ralph/tests/integration.spec.ts create mode 100644 packages/workflow/tool-ralph/tests/tool-ralph.spec.ts create mode 100644 packages/workflow/tool-ralph/tsconfig.json diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 50cb528951..c03469e83e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -83,6 +83,7 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_subagent_acp["subagent-acp"] + pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tool_tasks["tool-tasks"] @@ -186,6 +187,7 @@ flowchart LR svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy + svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs @@ -209,6 +211,7 @@ flowchart LR svc_userInteraction --> pkg_stdio_demo svc_userInteraction --> pkg_tool_ask_user svc_web --> pkg_tool_web + svc_workflows --> pkg_tool_ralph svc_workflows --> pkg_tool_workflow svc_fs -. event gate .-> pkg_fs_policy ``` @@ -236,10 +239,10 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0cbe1854e1..dbf7ded411 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1133,6 +1133,24 @@ export interface Config { Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) +## `@deepseek-ai/dsh-tool-ralph` + +Requires: `tools` · `workflows` · `subagents` · `systemPrompt` + +```ts config-catalog +/** Deployment policy for the fixed Ralph workflow. */ +export interface Config { + /** Fresh structured-output provider used for every round (default `spawn`). */ + subagentProvider?: string + /** Default and deployment ceiling for one call's round count (default 256). */ + maxRounds?: number + /** Maximum serialized characters in one structured handoff (default 16384). */ + maxHandoffChars?: number +} +``` + +Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 40b17a3112..6991ecbd00 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -8,7 +8,7 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` for the run, but the script cannot observe or replace it. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv /** @@ -26,6 +26,12 @@ interface WorkflowStartRequest { meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** + * Optional engine-wide child-provider override for this run. The workflow + * script cannot observe or replace it; omission uses the engine's configured + * provider. + */ + subagentProvider?: string /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ diff --git a/docs/glossary.md b/docs/glossary.md index 9a27ead799..945eb60ef1 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -34,3 +34,9 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. + +## Ralph + +- **Ralph loop** — one foreground fresh-agent workflow run toward an immutable objective. It is a model-facing tool policy composed from workflow and subagent primitives, not a same-session goal, agent-loop mode, scheduler, or generic workflow-script feature. +- **Ralph round** — one fresh child session in a [Ralph loop](#ralph-loop). The child receives no parent or prior-child conversation seed; the shared workspace and one bounded [Ralph handoff](#ralph-handoff) carry cross-round state. +- **Ralph handoff** — the normalized bounded structured report passed from one continuing Ralph round to the next, containing status, summary, evidence, next steps, and blocker text. It supplements the shared workspace rather than replacing it as authority. diff --git a/docs/module-graph.md b/docs/module-graph.md index a9b4876334..95562bebca 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -154,6 +154,7 @@ flowchart TD pkg_tool_tasks["tool-tasks"] end subgraph group_workflow["packages/workflow"] + pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] @@ -434,6 +435,12 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_tasks pkg_agent_spine_demo --> pkg_tools pkg_agent_spine_demo --> pkg_workspace_context + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm @@ -572,6 +579,7 @@ flowchart TD | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 90e508da27..6bb0cc8202 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -90,6 +90,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | | [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | +| [Fresh-agent Ralph workflow tool](implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md) | 2026-07-19 | | [Human `/goal` command](implemented/feature/2026-07-19-human-goal-command.md) | 2026-07-19 | | [Model-facing same-session goal tools](implemented/feature/2026-07-19-model-facing-goal-tools.md) | 2026-07-19 | | [Persisted same-session goal domain](implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) | 2026-07-19 | diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml new file mode 100644 index 0000000000..4b56f36113 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-fresh-agent-ralph-workflow-tool.md: 71f83f25018fc930fd058777f6909ea94c5a77cb +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 0f3fc7910dc726436605f12e292f18bee84be0dd diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md new file mode 100644 index 0000000000..71f83f2501 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md @@ -0,0 +1,67 @@ +# RFC: Fresh-agent Ralph workflow tool + +Status: implemented + +English | [中文](2026-07-19-fresh-agent-ralph-workflow-tool.zh.md) + +## Problem + +Same-session goals preserve conversation and let one agent continue a durable objective, while the general workflow tool lets the model write a fan-out orchestration script. Neither is the Ralph pattern: repeatedly give the same objective to a completely fresh worker, use the shared workspace as long-term memory, and carry only a small explicit handoff until work completes or a limit is reached. + +Adding Ralph behavior to `dsh-agent-loop`, the goal driver, or the public model-written workflow language would couple one policy to unrelated execution machinery. Letting each child inherit the parent conversation would also defeat context reset and make replay depend on a growing implicit prefix. The feature needs a fixed, reviewable policy built from existing plugin primitives, with cancellation quiescence, bounded cross-round data, a generous configurable cap, and no novel human-facing goal state. + +## Decision + +Add `@deepseek-ai/dsh-tool-ralph` as a separate consumer package under `packages/workflow/`. It registers `ralph({ objective, maxRounds? })`, owns a fixed workflow script, and depends only on `ctx.tools`, `ctx.systemPrompt`, `ctx.workflows`, and `ctx.subagents`. A Ralph run is not a session goal, creates no goal state, and requires no branch in the concrete agent loop. + +The tool is foreground-only. The calling agent parents every child for cwd and lineage, the parent tool call waits for the complete run, and the parent step's abort signal cancels the workflow. `run.dispose()` is awaited on every path, so cancellation reaches the worker engine's bounded settlement and child quiescence before the call returns. + +### Per-run workflow provider route + +`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider and uses the result for every `agent()` call in the run. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged. + +The Ralph plugin's `subagentProvider` defaults to `spawn`. Immediately before a call it requires the named provider to exist, support structured output, and report `inheritsParentContext: false`; a fork-like or incapable provider fails loudly before workflow start. Provider lookup remains call-time because effect-scoped provider registration can change under HMR. + +### Ralph rounds and handoff + +The hierarchy is Ralph Run → Ralph Round → fresh child Turn → Step. One Ralph round creates exactly one child through the selected provider. Spawn gives that child a distinct session with no seed while preserving the parent's cwd, so the shared working tree is the durable authority and neither parent conversation nor prior child history enters the request. + +The fixed prompt passes only the immutable objective, current round and cap, a workspace-as-authority instruction, and the previous structured report. A `RalphRoundReport` contains `status: continue | complete | blocked`, `summary`, `evidence`, `nextSteps`, and `blocker`. Strings must be normalized; `continue` requires next steps and no blocker, `complete` requires evidence with no next steps or blocker, and `blocked` requires a concrete blocker. The script validates semantics and serialized size before the report can become the next handoff; the consumer validates the materialized terminal value again across the workflow seam. + +`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` defaults to `16384`. Both are positive safe-integer config values, and oversized reports fail rather than being silently truncated. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started. + +### Model and UI surface + +The model may supply only `objective` and optional `maxRounds`; provider selection, report schema, handoff cap, and script are deployment-owned. A fixed prompt section says to use `ralph` only when the direct human explicitly asks for Ralph or fresh-agent iteration, and distinguishes it from same-session goals, bounded delegation, and general fan-out workflows. This is guidance rather than a new goal UX state machine. + +ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. The parent transcript retains the original tool call and one bounded terminal report, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows. + +## Testing + +Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing, all three terminal outcomes, malformed and oversized boundary values, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove that a per-run provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node. + +A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, terminal completion, and disposal of both children. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. + +## Alternatives considered + +- **Put Ralph in the same-session goal driver** — rejected because goal rounds intentionally preserve one conversation, while Ralph's defining property is a fresh context per round; combining them would make goal lifecycle and child orchestration inseparable. +- **Expose a `fresh` or loop flag on the general workflow tool** — rejected because the model-written script surface should remain general and provider-neutral; Ralph's fixed report protocol and stop policy deserve one reviewable consumer. +- **Use `subagent_fork` for replay convenience** — rejected because inherited completed turns are implicit, growing handoff state and violate the fresh-context contract. The workspace plus one structured report is replayable without inserting artificial cancellation records. +- **Call the subagent seam directly from the tool** — rejected because the existing workflow engine already owns foreground orchestration, structured children, cancellation propagation, worker termination, events, and quiescent disposal. Reusing it demonstrates plugin composition instead of building a second loop runtime. +- **Silently truncate a large report** — rejected because truncation can remove status evidence or next steps while still looking like an authoritative handoff. A producer must emit a valid report within the configured bound. + +## Consequences + +- Fresh-agent iteration is a first-class model tool implemented entirely as a removable plugin over existing seams. +- Goal rounds and Ralph rounds stay different concepts: the former is one same-session continuation turn, while the latter is one fresh child inside a foreground workflow. +- The workspace becomes authoritative cross-round memory, so workers must inspect and verify it rather than trusting a narrative handoff. +- A generous round ceiling permits substantial autonomous work, while deployment config still bounds child count and every handoff remains size-limited. +- Provider routing becomes an explicit workflow start concern without expanding the script or ordinary workflow tool surface. + +## Known limitations and deferred work + +- Completion and blocker status are worker self-declarations. An independent evaluator, evaluator-driven feedback round, completion certificate, or adversarial verifier is intentionally deferred. +- Runs are foreground and process-local. Background collection, persistence/resume, scheduling, and restart recovery are absent. +- Round count is the only aggregate budget. Token, currency, elapsed-time, and provider-usage budgets remain separate future policy. +- One round creates one child. Within-round fan-out, evaluator/worker role separation, dynamic provider or model selection, and cross-run journals are deferred. +- Prompt guidance asks models not to invoke Ralph recursively; a structural child-tool restriction would require a separately designed workflow child-policy surface. diff --git a/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md new file mode 100644 index 0000000000..0f3fc7910d --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -0,0 +1,67 @@ +# RFC:全新 agent Ralph 工作流工具 + +Status: implemented + +[English](2026-07-19-fresh-agent-ralph-workflow-tool.md) | 中文 + +## 问题 + +同会话目标会保留对话,让一个 agent 持续完成持久目标;通用工作流工具则让模型编写扇出编排脚本。两者都不是 Ralph 模式:把同一目标反复交给完全全新的工作者,以共享工作区作为长期记忆,并且在各轮之间只传递一份小型显式交接,直到工作完成或触及限制。 + +如果把 Ralph 行为加入 `dsh-agent-loop`、目标驱动器或面向模型的公开工作流语言,就会让一项策略与无关的执行机制耦合。让每个子 agent 继承父对话也会破坏上下文重置,并让重放依赖不断增长的隐式前缀。此功能需要一项由现有插件原语组合而成的固定、可评审策略,同时具备取消静止性、有界跨轮数据、宽裕且可配置的上限,并且不引入新颖的面向人类目标状态。 + +## 决策 + +在 `packages/workflow/` 下新增独立消费者包 `@deepseek-ai/dsh-tool-ralph`。它注册 `ralph({ objective, maxRounds? })`,拥有固定工作流脚本,并且只依赖 `ctx.tools`、`ctx.systemPrompt`、`ctx.workflows` 和 `ctx.subagents`。Ralph 运行不是会话目标,不会创建目标状态,也不要求在具体 agent loop 中增加分支。 + +该工具仅以前台方式运行。调用 agent 作为每个子 agent 的父级以提供 cwd 和谱系,父工具调用等待整次运行结束,父步骤的中止信号会取消工作流。每条路径都会等待 `run.dispose()`,因此调用返回前,取消会经过工作流引擎的有界收敛并达到子 agent 静止状态。 + +### 每次运行的工作流 provider 路由 + +`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider,并把结果用于该运行中的每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。 + +Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。 + +### Ralph 轮次与交接 + +层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。 + +固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。 + +`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 默认为 `16384`。两者都是正安全整数配置值;过大的报告会失败,而不会被静默截断。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。 + +### 模型与 UI 表面 + +模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。 + +ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。父转录只保留原始工具调用和一份有界终止报告,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 + +## 测试 + +单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由、三种终止结果、畸形及过大边界值、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明,每次运行的 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 + +一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、终止完成以及两个子 agent 都被处置。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 + +## 考虑过的替代方案 + +- **把 Ralph 放进同会话目标驱动器** — 拒绝,因为目标轮次有意保留同一段对话,而 Ralph 的定义性属性是每轮使用全新上下文;合并两者会让目标生命周期与子 agent 编排无法分离。 +- **在通用工作流工具上暴露 `fresh` 或循环标志** — 拒绝,因为模型编写的脚本表面应保持通用且与 provider 无关;Ralph 的固定报告协议和停止策略值得拥有一个可评审消费者。 +- **为了方便重放而使用 `subagent_fork`** — 拒绝,因为继承的已完成回合是隐式、不断增长的交接状态,并违反全新上下文契约。工作区加一份结构化报告即可重放,无需插入人为取消记录。 +- **让工具直接调用 subagent 接缝** — 拒绝,因为现有工作流引擎已经拥有前台编排、结构化子 agent、取消传播、工作线程终止、事件和静止处置。复用它可以展示插件组合,而不是构建第二个循环运行时。 +- **静默截断大型报告** — 拒绝,因为截断可能删除状态证据或下一步,却仍看似权威交接。生产者必须在配置边界内发出有效报告。 + +## 后果 + +- 全新 agent 迭代成为一项一等模型工具,并完全以现有接缝之上的可移除插件实现。 +- 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。 +- 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。 +- 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 +- provider 路由成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 + +## 已知限制与推迟工作 + +- 完成与阻塞状态由工作者自行声明。独立 evaluator、evaluator 驱动的反馈轮次、完成证书或对抗式 verifier 被有意推迟。 +- 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。 +- 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。 +- 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。 +- 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 64fd5e692d..8d73477bd5 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | @@ -482,6 +483,35 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. +## `@deepseek-ai/dsh-tool-ralph` + +### `ralph` + +Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. + +```json +{ + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] +} +``` + +Source: [`packages/workflow/tool-ralph/src/index.ts`](../packages/workflow/tool-ralph/src/index.ts) + +A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 194ff3dee0..56074f9b99 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -41,6 +41,8 @@ flowchart LR cfg --> plugin_acp_workflow_workerthread plugin_acp_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_acp_tool_workflow + plugin_acp_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_acp_tool_ralph plugin_acp_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_acp_tool_todo plugin_acp_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] @@ -66,6 +68,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | | `hooks-claude` | `@deepseek-ai/dsh-hooks-claude` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index cb8890dae7..5c70e5851b 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -86,6 +86,9 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' + +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' # `todo_write` replaces the logged whole list and surfaces an ACP `plan` update. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 5ba28dca73..867bbcc7b7 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -72,6 +74,13 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index c27f1788e7..bebf7b25df 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -130,6 +130,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 9b14172e43..e974d937e7 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -55,6 +57,13 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 5112f55a25..2ffcf4f04a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -73,6 +73,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "run_code", "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 9b14172e43..e974d937e7 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -55,6 +57,13 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { /** The exact skill name from the available skills list. */ diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index ec0b4430e1..ec7e2758a8 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -22,6 +22,8 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -72,6 +74,13 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. */ get_goal(args: Record): Promise; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph(args: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + }): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { /** Path to read, resolved by the filesystem backend. */ diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index f81876701a..751040c92e 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -16,6 +16,8 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + You are an AI agent powered by the DeepSeek Harness SDK. @@ -35,3 +37,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index bae7be5f6b..09b42a7ac1 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -73,6 +73,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -417,6 +437,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 82335a7f1e..472db36968 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -15,6 +15,8 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + You are an AI agent powered by the DeepSeek Harness SDK. @@ -34,3 +36,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index bae7be5f6b..09b42a7ac1 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -73,6 +73,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -417,6 +437,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 87818d5b6d..6bc89dccd5 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -15,3 +15,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 0680b50e05..99c8f338a3 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -73,6 +73,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 87818d5b6d..6bc89dccd5 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -15,3 +15,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 0680b50e05..99c8f338a3 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -73,6 +73,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 6f49fcf1c2..cc8fc47873 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index 0ef5307126..0fcb95895c 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -103,6 +103,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md index 4af39f6b4b..dc934714b9 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md @@ -21,3 +21,5 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json index 0ef5307126..0fcb95895c 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json @@ -103,6 +103,26 @@ "properties": {} } }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, { "name": "read", "description": "Read a UTF-8 text file and return line-numbered content.", diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 285263146f..10667a6101 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -1,6 +1,6 @@ # headless-agent -Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. +Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. ## Run it diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9533af28d3..3e5876c2b6 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -37,6 +37,8 @@ flowchart LR cfg --> plugin_headless_workflow_workerthread plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_headless_tool_workflow + plugin_headless_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_headless_tool_ralph plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_headless_tool_todo plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -60,6 +62,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..766e8b7891 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -81,6 +81,11 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' +# A separate fixed consumer demonstrates fresh-agent Ralph iteration without +# changing the workflow tool or same-session goal behavior. +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + # `todo_write` replaces the logged whole list. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/package.json b/examples/package.json index f893ab6bc2..04931f2fc1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/examples/repl-agent/README.md b/examples/repl-agent/README.md index 7145873e9a..d86b18dcb4 100644 --- a/examples/repl-agent/README.md +++ b/examples/repl-agent/README.md @@ -1,6 +1,6 @@ # repl-agent -The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. +The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. ## Run it @@ -52,7 +52,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | +| `workflow-workerthread`, `tool-workflow`, `tool-ralph` | the worker-thread engine, general model-written `workflow` tool, and separate fixed fresh-agent `ralph` consumer, with children routed through spawn | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | | `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md index af3f810585..faa5825d9f 100644 --- a/examples/repl-agent/composition.md +++ b/examples/repl-agent/composition.md @@ -41,6 +41,8 @@ flowchart LR cfg --> plugin_repl_workflow_workerthread plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_repl_tool_workflow + plugin_repl_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_repl_tool_ralph plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] cfg --> plugin_repl_tool_todo plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -74,6 +76,7 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index 4428d5f3a8..5a7ca0f62f 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -94,6 +94,9 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' + +- id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' # `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 2c62ba25e6..a9f937f883 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -22,6 +22,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' import { createTuiChat } from '@deepseek-ai/dsh-tui' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -180,6 +181,7 @@ async function mountScenarioContext( await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) await ctx.plugin(ToolWorkflow) + await ctx.plugin(ToolRalph) await ctx.plugin(CommandService) if (scenario.composition === 'code' || scenario.composition === 'advanced') { await ctx.plugin(WorkerCodeRuntime, {}) diff --git a/packages/README.md b/packages/README.md index f2298a47fa..d9b11a1f3b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -20,7 +20,7 @@ Packages live at `packages///`; groups are containers, while names r | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | -| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | +| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: the storage seam, a local impl, and the tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d2e88e0707..0becd20a65 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1791,7 +1791,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkflowStartRequest', - declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n parent: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n parent: Agent;\n signal?: AbortSignal;\n}', }, { name: 'WorkflowStopReason', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index c6b7f9a804..3c99ecbd4d 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 98fa3cfe04..7c503b1aaf 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -7,7 +7,8 @@ The workflow seam: a model-written JavaScript orchestration script that fans out | `workflow/` | Abstract workflow seam: service base class + run vocabulary + `workflow/*` events | `ctx.workflows` | | `workflow-workerthread/` | `node:worker_threads` engine: one worker per run; the script's vm context lives inside the worker, `agent()` bridges to `ctx.subagents` over the message port | (provides `ctx.workflows`) | | `tool-workflow/` | Model-facing `workflow` tool over `ctx.workflows` | (registers on `ctx.tools`) | +| `tool-ralph/` | Fixed fresh-agent Ralph policy over `ctx.workflows` and a fresh structured-output subagent provider | (registers on `ctx.tools`) | The interface lives at `workflow/workflow/`. The engine's `agent()` hook rides the [subagent seam](../subagent/README.md) (any registered provider; the shipped examples use `spawn`), and `agent({ schema })` rides the structured-output support the in-process backends implement. The worker thread isolates the SCRIPT — the host never blocks on it, and a cancelled run's post-grace termination is real — but it is NOT a security boundary; an isolated-vm/separate-process engine (actual sandboxing) swaps in behind the same interface if that ever matters. -The proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). +The general script engine's proposal, decisions, and deferred work: [docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md](../../docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md). The separate Ralph consumer fixes the script and fresh-provider policy rather than adding another engine or an agent-loop mode. diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md new file mode 100644 index 0000000000..4b42da7cf5 --- /dev/null +++ b/packages/workflow/tool-ralph/README.md @@ -0,0 +1,87 @@ +# @deepseek-ai/dsh-tool-ralph + +The model-facing `ralph` tool runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. It demonstrates a specialized orchestration policy as an ordinary plugin over [`ctx.workflows`](../workflow/README.md) and [`ctx.subagents`](../../subagent/subagent/README.md): no Ralph mode or fresh-agent loop is added to `agent-loop`, and the same-session [goal domain](../../goal/goal/README.md) remains independent. + +## Contract + +`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. + +Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion. + +The terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Child self-declaration determines completion in this cut. A workflow failure or cancellation is an error result; partial output is never success. + +## Lifecycle and cancellation + +The caller's agent is the parent of every fresh child, preserving cwd and lineage without copying its conversation. `exec.signal` enters the workflow engine and is also bridged to `run.cancel()` for implementation independence. The tool awaits `run.result` and calls `run.dispose()` in `finally`, so a cancelled parent step waits for the engine's bounded termination and child quiescence before returning. + +## Render intent + +The pending call is a `generic` card titled `ralph`; the immutable objective is its `rawInput`. The result keeps the generic card. Both presentation functions depend only on tool arguments and the settled tool envelope. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. | +| `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. | +| `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. | + +All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR. + +## Model Experience + +### System prompt + +#### What the model sees + +Every parent request in this plugin's registration scope receives the fixed routing guidance below. + +##### Ralph guidance + +```markdown +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +``` + +#### Token effect + +Small fixed guidance cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + +### Tool schema + +#### What the model sees + +The generated [`ralph` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ralph) exposes one required `objective` string and one optional `maxRounds` number. Provider choice, handoff size, report schema, workflow script, and orchestration behavior are deployment-owned and absent from the call surface. + +#### Token effect + +Small fixed schema cost on each request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while the definition and visibility are unchanged. + +### Child requests and parent result + +#### What the model sees + +Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. + +#### Token effect + +Every round pays for a fresh child context. The parent result is bounded indirectly by `maxHandoffChars`; child work remains outside the parent context. + +#### KV Cache effect + +Each fresh child has an independent request cache. The parent result appends after the reusable request prefix. + +## Known Limitations and Deferred Work + +- **Completion is worker self-declaration** — there is no independent evaluator or verifier deciding whether the objective is actually complete; evaluator policy and evaluator-driven continuation are deferred. +- **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy. +- **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child. +- **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider. +- **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred. diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json new file mode 100644 index 0000000000..31c368b6f4 --- /dev/null +++ b/packages/workflow/tool-ralph/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-tool-ralph", + "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts new file mode 100644 index 0000000000..83da19b119 --- /dev/null +++ b/packages/workflow/tool-ralph/src/index.ts @@ -0,0 +1,384 @@ +/** + * Model-facing foreground Ralph loop over the workflow and subagent seams. A + * fixed script starts one fresh structured-output child per round, carrying + * only the immutable objective and the previous bounded handoff between them. + * @module @deepseek-ai/dsh-tool-ralph + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' +// Declaration merge only: makes ctx.systemPrompt visible for section registration. +import type {} from '@deepseek-ai/dsh-system-prompt' + +export const name = 'tool-ralph' +export const inject = ['tools', 'workflows', 'subagents', 'systemPrompt'] + +/** Deployment policy for the fixed Ralph workflow. */ +export interface Config { + /** Fresh structured-output provider used for every round (default `spawn`). */ + subagentProvider?: string + /** Default and deployment ceiling for one call's round count (default 256). */ + maxRounds?: number + /** Maximum serialized characters in one structured handoff (default 16384). */ + maxHandoffChars?: number +} + +/** Schemastery configuration for the Ralph tool. */ +export const Config: z = z.object({ + subagentProvider: z.string().default('spawn'), + maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256), + maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384), +}) + +interface ResolvedConfig { + readonly subagentProvider: string + readonly maxRounds: number + readonly maxHandoffChars: number +} + +type RalphRoundStatus = 'continue' | 'complete' | 'blocked' + +interface RalphRoundReport { + readonly status: RalphRoundStatus + readonly summary: string + readonly evidence: string[] + readonly nextSteps: string[] + readonly blocker: string +} + +type RalphRunStatus = 'complete' | 'blocked' | 'budget-limited' + +interface RalphRunResult { + readonly status: RalphRunStatus + readonly roundsStarted: number + readonly report: RalphRoundReport +} + +interface RalphCallArgs { + objective: string + maxRounds?: number +} + +const RALPH_META = { + name: 'ralph-loop', + description: 'Iterate toward one objective with a fresh child and bounded structured handoff per round.', + phases: [{ title: 'Fresh-agent rounds', detail: 'One clean child context per Ralph round.' }], +} + +/** + * Fixed, deployment-owned orchestration. The model supplies data only; it + * cannot alter the loop, provider route, schema, or handoff validation. + */ +const RALPH_SCRIPT = String.raw` +const reportSchema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['continue', 'complete', 'blocked'] }, + summary: { type: 'string' }, + evidence: { type: 'array', items: { type: 'string' } }, + nextSteps: { type: 'array', items: { type: 'string' } }, + blocker: { type: 'string' }, + }, + required: ['status', 'summary', 'evidence', 'nextSteps', 'blocker'], + additionalProperties: false, +} + +function normalizedText(value) { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function normalizedList(value) { + return Array.isArray(value) && value.every(normalizedText) +} + +function validateReport(report) { + if (report === null || typeof report !== 'object' || Array.isArray(report)) { + throw new Error('Ralph child returned no structured round report') + } + if (!normalizedText(report.summary)) { + throw new Error('Ralph round report summary must be non-empty and normalized') + } + if (!normalizedList(report.evidence) || !normalizedList(report.nextSteps)) { + throw new Error('Ralph round report evidence and nextSteps must contain only non-empty normalized strings') + } + if (typeof report.blocker !== 'string' || report.blocker !== report.blocker.trim()) { + throw new Error('Ralph round report blocker must be a normalized string') + } + switch (report.status) { + case 'continue': + if (report.nextSteps.length === 0 || report.blocker !== '') { + throw new Error('a continuing Ralph report needs nextSteps and an empty blocker') + } + break + case 'complete': + if (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '') { + throw new Error('a complete Ralph report needs evidence, no nextSteps, and an empty blocker') + } + break + case 'blocked': + if (!normalizedText(report.blocker)) { + throw new Error('a blocked Ralph report needs a concrete blocker') + } + break + default: + throw new Error('Ralph round report status is invalid') + } + const serialized = JSON.stringify(report) + if (serialized.length > args.maxHandoffChars) { + throw new Error('Ralph round report exceeds maxHandoffChars (' + serialized.length + ' > ' + args.maxHandoffChars + ')') + } + return report +} + +let previous +for (let round = 1; round <= args.maxRounds; round += 1) { + phase('Fresh-agent rounds') + const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous) + const prompt = [ + 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.', + 'Immutable objective:\n' + args.objective, + 'Ralph round: ' + round + ' of ' + args.maxRounds + '.', + 'The shared workspace and its current working tree are the long-term memory and source of truth. Inspect them before acting, preserve existing work, perform concrete in-scope work, and verify what you change. Treat the previous report only as a bounded handoff; confirm it against the workspace.', + 'Previous structured handoff:\n' + prior, + 'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.', + ].join('\n\n') + const report = validateReport(await agent(prompt, { + label: 'Ralph round ' + round, + phase: 'Fresh-agent rounds', + schema: reportSchema, + })) + if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report } + if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report } + previous = report +} +return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previous } +` + +const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. ' + + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round ' + + 'opens a new child with no parent conversation or prior child session; the shared workspace is ' + + 'long-term memory, and only a bounded structured report crosses rounds. The call returns on ' + + 'completion, a concrete blocker, or the round limit. Ordinary long-running same-session work ' + + 'belongs to goal tools.' + +/** Validate defaults even when a caller invokes apply() without Loader normalization. */ +function resolveConfig(config: Config): ResolvedConfig { + const subagentProvider = config.subagentProvider ?? 'spawn' + const maxRounds = config.maxRounds ?? 256 + const maxHandoffChars = config.maxHandoffChars ?? 16_384 + if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) { + throw new TypeError('subagentProvider must be a non-empty normalized string') + } + if (!Number.isSafeInteger(maxRounds) || maxRounds < 1) { + throw new TypeError('maxRounds must be a positive safe integer') + } + if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) { + throw new TypeError('maxHandoffChars must be a positive safe integer') + } + return { subagentProvider, maxRounds, maxHandoffChars } +} + +/** Resolve one model-selected cap against the deployment ceiling. */ +function resolveMaxRounds(requested: number | undefined, ceiling: number): number { + const value = requested ?? ceiling + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError('Ralph maxRounds must be a positive safe integer') + } + if (value > ceiling) { + throw new TypeError(`Ralph maxRounds ${value} exceeds the deployment ceiling ${ceiling}`) + } + return value +} + +/** Require the configured route to mean a genuinely fresh structured child. */ +function requireFreshProvider(ctx: Context, name: string): SubagentProvider { + const provider = ctx.subagents.getProvider(name) + if (provider === undefined) { + throw new Error(`Ralph subagent provider "${name}" is not registered`) + } + if (!provider.capabilities.outputSchema) { + throw new Error(`Ralph subagent provider "${name}" does not support structured output`) + } + if (provider.inheritsParentContext) { + throw new Error(`Ralph subagent provider "${name}" inherits parent context; Ralph requires a fresh provider`) + } + return provider +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizedText(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function normalizedList(value: unknown): value is string[] { + return Array.isArray(value) && value.every(normalizedText) +} + +/** Defensively decode the fixed script's report across an implementation seam. */ +function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: number): RalphRoundReport { + if (!isRecord(value) + || Object.keys(value).sort().join(',') !== 'blocker,evidence,nextSteps,status,summary' + || value['status'] !== expectedStatus + || !normalizedText(value['summary']) + || !normalizedList(value['evidence']) + || !normalizedList(value['nextSteps']) + || typeof value['blocker'] !== 'string' + || value['blocker'] !== value['blocker'].trim()) { + throw new Error('Ralph workflow returned a malformed round report') + } + const report: RalphRoundReport = { + status: expectedStatus, + summary: value['summary'], + evidence: value['evidence'], + nextSteps: value['nextSteps'], + blocker: value['blocker'], + } + if (expectedStatus === 'continue' && (report.nextSteps.length === 0 || report.blocker !== '')) { + throw new Error('Ralph workflow returned an invalid continuing report') + } + if (expectedStatus === 'complete' + && (report.evidence.length === 0 || report.nextSteps.length !== 0 || report.blocker !== '')) { + throw new Error('Ralph workflow returned an invalid completion report') + } + if (expectedStatus === 'blocked' && !normalizedText(report.blocker)) { + throw new Error('Ralph workflow returned an invalid blocked report') + } + const chars = JSON.stringify(report).length + if (chars > maxChars) { + throw new Error(`Ralph workflow returned an oversized handoff (${chars} > ${maxChars})`) + } + return report +} + +/** Defensively decode the fixed script's terminal value. */ +function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphRunResult { + if (!isRecord(value) + || Object.keys(value).sort().join(',') !== 'report,roundsStarted,status' + || typeof value['roundsStarted'] !== 'number' + || !Number.isSafeInteger(value['roundsStarted']) + || value['roundsStarted'] < 1 + || value['roundsStarted'] > maxRounds) { + throw new Error('Ralph workflow returned a malformed terminal result') + } + const roundsStarted = value['roundsStarted'] + switch (value['status']) { + case 'complete': + return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) } + case 'blocked': + return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) } + case 'budget-limited': + if (roundsStarted !== maxRounds) { + throw new Error('Ralph workflow returned budget-limited before the round limit') + } + return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) } + default: + throw new Error('Ralph workflow returned an unknown terminal status') + } +} + +/** A non-clean workflow finish is an error, never a partial Ralph success. */ +function stopReasonError(result: WorkflowResult): string | undefined { + switch (result.stopReason) { + case 'completed': + return undefined + case 'cancelled': + return `Ralph workflow was cancelled${result.error === undefined ? '' : ` (${result.error})`}` + case 'error': + return `Ralph workflow failed: ${result.error ?? 'unknown error'}` + /* v8 ignore start -- WorkflowStopReason is closed; a future variant must fail loud here. */ + default: + return `Ralph workflow ended abnormally (${String(result.stopReason satisfies never)})` + /* v8 ignore stop */ + } +} + +/** Render the fixed terminal envelope without dropping the bounded report. */ +function renderResult(result: RalphRunResult): string { + const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}` + switch (result.status) { + case 'complete': + return `Ralph completed after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + case 'blocked': + return `Ralph blocked after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + case 'budget-limited': + return `Ralph reached its ${rounds} limit with work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + } +} + +function presentCall(args: RalphCallArgs): ToolCallView { + return { card: 'generic', title: 'ralph', rawInput: args.objective } +} + +function presentResult(args: RalphCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView { + void args + void result + return { card: 'generic' } +} + +/** Register the fixed Ralph tool and its explicit-ask usage policy. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:ralph', + order: 116, + text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', + }) + ctx.tools.register(defineTool({ + name: 'ralph', + description: DESCRIPTION, + parameters: { + objective: { + type: 'string', + required: true, + description: 'The immutable completion objective for every fresh Ralph round.', + }, + maxRounds: { + type: 'number', + description: 'Optional positive safe-integer round cap, bounded by the deployment ceiling.', + }, + }, + async execute(args, exec): Promise { + const parent = exec.agent + if (parent === undefined) { + throw new Error('Ralph tool requires a calling agent (exec.agent was undefined)') + } + const objective = args.objective.trim() + if (objective.length === 0) throw new Error('Ralph objective must be a non-empty string') + const maxRounds = resolveMaxRounds(args.maxRounds, resolved.maxRounds) + void requireFreshProvider(ctx, resolved.subagentProvider) + + const run: WorkflowRun = ctx.workflows.start({ + script: RALPH_SCRIPT, + meta: RALPH_META, + args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars }, + subagentProvider: resolved.subagentProvider, + parent, + ...exec.signal === undefined ? {} : { signal: exec.signal }, + }) + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const settled = await run.result + const error = stopReasonError(settled) + if (error !== undefined) throw new Error(error) + const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars) + return [{ type: 'text', text: renderResult(value) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + await run.dispose() + } + }, + presentCall, + presentResult, + })) +} diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts new file mode 100644 index 0000000000..f984e67d22 --- /dev/null +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import * as Invariants from '@deepseek-ai/dsh-invariants' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' +import * as spawn from '@deepseek-ai/dsh-subagent-spawn' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as toolRalph from '../src/index.ts' + +describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { + it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => { + const firstReport = { + status: 'continue', + summary: 'ROUND_ONE_HANDOFF', + evidence: ['Created migration-a.ts.'], + nextSteps: ['Finish migration-b.ts.'], + blocker: '', + } + const finalReport = { + status: 'complete', + summary: 'Both migration slices are complete.', + evidence: ['Focused migration tests pass.'], + nextSteps: [], + blocker: '', + } + const ctx = new Context() + const adapter = new MockAdapter([ + textResponse('PARENT_HISTORY_MARKER'), + toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport), + toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), + ]) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(WorkerWorkflowEngine, {}) + await ctx.plugin(toolRalph, { maxRounds: 2 }) + ctx.llm.registerAdapter(['mock'], adapter) + + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('ralph-parent'), + meta: { cwd: '/tmp/ralph-shared-workspace' }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const parent = parentHandle.agent + parent.send([{ type: 'text', text: 'PARENT_PROMPT_MARKER' }]) + await parent.whenIdle() + + const children: Agent[] = [] + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + expect(agent).toBeDefined() + children.push(agent!) + }) + const result = await ctx.tools.execute({ + callId: CallId('ralph-integration'), + name: 'ralph', + arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, + agent: parent, + }) + + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 2 rounds.') + expect(children).toHaveLength(2) + expect(new Set(children.map(child => child.id)).size).toBe(2) + for (const child of children) { + expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace') + expect(child.session.header.parentSession).toBe(parent.session.header.id) + expect(child.session.header.seedLength).toBeUndefined() + expect(ctx.agents.get(child.id)).toBeUndefined() + } + + expect(adapter.requests).toHaveLength(3) + const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages) + const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages) + expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER') + expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER') + expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF') + expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER') + expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER') + expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF') + + await parentHandle.dispose() + }) +}) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts new file mode 100644 index 0000000000..b7a70fd7d7 --- /dev/null +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' +import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import * as toolRalph from '../src/index.ts' + +class StubEngine extends WorkflowService { + requests: WorkflowStartRequest[] = [] + cancels: string[] = [] + disposed = 0 + settle!: (result: WorkflowResult) => void + startError: Error | undefined + + start(request: WorkflowStartRequest): WorkflowRun { + if (this.startError !== undefined) throw this.startError + this.requests.push(request) + const result = new Promise((resolve) => { this.settle = resolve }) + return { + id: WorkflowRunId(`ralph-${this.requests.length}`), + meta: request.meta, + result, + cancel: (reason?: string) => { + this.cancels.push(reason ?? 'cancelled') + this.settle({ + value: null, + stopReason: 'cancelled', + ...reason === undefined ? {} : { error: reason }, + agentsStarted: 0, + }) + }, + dispose: () => { + this.disposed += 1 + return Promise.resolve() + }, + } + } +} + +class StubProvider implements SubagentProvider { + readonly name = 'fresh' + readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean + + constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) { + this.capabilities = { + outputSchema: options?.outputSchema ?? true, + depthLimit: true, + toolFilter: true, + persona: true, + } + this.inheritsParentContext = options?.inheritsParentContext ?? false + } + + start(_request: SubagentStartRequest): Promise { + return Promise.reject(new Error('StubProvider.start must not be reached behind StubEngine')) + } +} + +interface SetupOptions { + config?: toolRalph.Config + provider?: StubProvider | false +} + +async function setup(options?: SetupOptions) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider() + if (provider !== undefined) ctx.subagents.registerProvider(provider) + await ctx.plugin(StubEngine) + const config: toolRalph.Config = { subagentProvider: 'fresh' } + if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider + if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds + if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars + const fiber = await ctx.plugin(toolRalph, config) + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent + return { ctx, engine: ctx.workflows as StubEngine, parent, fiber } +} + +function execute( + ctx: Context, + args: unknown, + extra?: { agent?: Agent; signal?: AbortSignal }, +): Promise { + return ctx.tools.execute({ + callId: CallId('ralph-call'), + name: 'ralph', + arguments: args, + ...extra?.agent === undefined ? {} : { agent: extra.agent }, + ...extra?.signal === undefined ? {} : { signal: extra.signal }, + }) +} + +const CONTINUE = { + status: 'continue', + summary: 'Implemented the first slice.', + evidence: ['Focused tests pass.'], + nextSteps: ['Implement the second slice.'], + blocker: '', +} + +const COMPLETE = { + status: 'complete', + summary: 'The objective is complete.', + evidence: ['All required gates pass.'], + nextSteps: [], + blocker: '', +} + +const BLOCKED = { + status: 'blocked', + summary: 'No local work can progress.', + evidence: ['The required remote service is unavailable.'], + nextSteps: ['Retry after service recovery.'], + blocker: 'The required remote service is unavailable.', +} + +async function settleCompleted( + engine: StubEngine, + pending: Promise, + value: unknown, + agentsStarted = 1, +): Promise { + await vi.waitFor(() => { expect(engine.requests.length).toBeGreaterThan(0) }) + engine.settle({ value, stopReason: 'completed', agentsStarted }) + return pending +} + +describe('dsh-tool-ralph', () => { + it('starts the fixed workflow through the configured fresh provider and renders completion', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 9, maxHandoffChars: 9000 } }) + const pending = execute(ctx, { objective: ' Finish the migration. ', maxRounds: 4 }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + expect(engine.requests[0]).toMatchObject({ + meta: { name: 'ralph-loop' }, + args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 }, + subagentProvider: 'fresh', + parent, + }) + expect(engine.requests[0]!.script).toContain("status: 'budget-limited'") + const result = await settleCompleted(engine, pending, { + status: 'complete', + roundsStarted: 1, + report: COMPLETE, + }) + expect(result.isError).toBe(false) + expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 1 round.') + expect((result.content[0] as { text: string }).text).toContain('All required gates pass.') + expect(engine.disposed).toBe(1) + }) + + it('renders blocked and budget-limited terminal outcomes as bounded successful results', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } }) + const blocked = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + const blockedResult = await settleCompleted(engine, blocked, { + status: 'blocked', + roundsStarted: 2, + report: BLOCKED, + }, 2) + expect((blockedResult.content[0] as { text: string }).text).toContain('Ralph blocked after 2 rounds.') + + const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + const limitedResult = await settleCompleted(engine, limited, { + status: 'budget-limited', + roundsStarted: 2, + report: CONTINUE, + }, 2) + expect((limitedResult.content[0] as { text: string }).text) + .toContain('Ralph reached its 2 rounds limit with work remaining.') + }) + + it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => { + const { ctx, engine, parent } = await setup() + const failed = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settle({ value: null, stopReason: 'error', error: 'child report malformed', agentsStarted: 1 }) + expect(((await failed).content[0] as { text: string }).text) + .toContain('Ralph workflow failed: child report malformed') + + const unknown = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 }) + expect(((await unknown).content[0] as { text: string }).text).toContain('unknown error') + + const cancelled = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(3) }) + engine.settle({ value: null, stopReason: 'cancelled', error: 'user stopped', agentsStarted: 0 }) + expect(((await cancelled).content[0] as { text: string }).text).toContain('cancelled (user stopped)') + + const bare = execute(ctx, { objective: 'Work.' }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(4) }) + engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 }) + expect(((await bare).content[0] as { text: string }).text).toMatch(/cancelled$/) + expect(engine.disposed).toBe(4) + }) + + it('bridges mid-flight and already-aborted parent signals to cancellation', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + controller.abort() + expect((await pending).isError).toBe(true) + + const already = new AbortController() + already.abort() + expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true) + expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted']) + expect(engine.disposed).toBe(2) + }) + + it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 3 } }) + expect((await execute(ctx, { objective: 'Work.' })).isError).toBe(true) + expect((await execute(ctx, { objective: ' ' }, { agent: parent })).isError).toBe(true) + for (const maxRounds of [0, 1.5, Number.NaN, 4]) { + expect((await execute(ctx, { objective: 'Work.', maxRounds }, { agent: parent })).isError).toBe(true) + } + const missing = await execute(ctx, {}, { agent: parent }) + expect(missing.error?.code).toBe('INVALID_ARGS') + expect(engine.requests).toHaveLength(0) + }) + + it('rejects missing, unstructured, and parent-context-inheriting provider routes', async () => { + const missing = await setup({ provider: false }) + expect(((await execute(missing.ctx, { objective: 'Work.' }, { agent: missing.parent })).content[0] as { text: string }).text) + .toContain('is not registered') + expect(missing.engine.requests).toHaveLength(0) + + const unstructured = await setup({ provider: new StubProvider({ outputSchema: false }) }) + expect(((await execute(unstructured.ctx, { objective: 'Work.' }, { agent: unstructured.parent })).content[0] as { text: string }).text) + .toContain('does not support structured output') + + const inherited = await setup({ provider: new StubProvider({ inheritsParentContext: true }) }) + expect(((await execute(inherited.ctx, { objective: 'Work.' }, { agent: inherited.parent })).content[0] as { text: string }).text) + .toContain('inherits parent context') + }) + + it('rejects invalid direct-apply config before touching injected services', () => { + expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized') + expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer') + expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer') + }) + + it('turns malformed fixed-workflow terminal values and reports into errors', async () => { + const cases: { value: unknown; message: string; config?: toolRalph.Config }[] = [ + { value: null, message: 'malformed terminal result' }, + { value: { status: 'complete', roundsStarted: 0, report: COMPLETE }, message: 'malformed terminal result' }, + { value: { status: 'complete', roundsStarted: 3, report: COMPLETE }, message: 'malformed terminal result', config: { maxRounds: 2 } }, + { value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } }, + { value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' }, + { value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' }, + { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } }, + ] + for (const testCase of cases) { + const { ctx, engine, parent } = await setup( + testCase.config === undefined ? undefined : { config: testCase.config }, + ) + const result = await settleCompleted( + engine, + execute(ctx, { objective: 'Work.', ...testCase.config?.maxRounds === undefined ? {} : { maxRounds: testCase.config.maxRounds } }, { agent: parent }), + testCase.value, + ) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain(testCase.message) + } + }) + + it('surfaces a synchronous engine start failure without inventing a run', async () => { + const { ctx, engine, parent } = await setup() + engine.startError = new Error('engine refused fixed script') + const result = await execute(ctx, { objective: 'Work.' }, { agent: parent }) + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('engine refused fixed script') + expect(engine.disposed).toBe(0) + }) + + it('registers scoped guidance and pure replay-safe generic presentation', async () => { + const { ctx, fiber } = await setup() + const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph') + expect(section?.text).toContain('ONLY when the direct human explicitly asks') + const tool = ctx.tools.get('ralph')! + expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({ + card: 'generic', + title: 'ralph', + rawInput: 'Finish it.', + }) + expect(tool.presentResult!({ objective: 'Finish it.' }, { content: [], isError: false })).toEqual({ card: 'generic' }) + expect(tool.presentCall!({ nope: true })).toBeUndefined() + await fiber.dispose() + expect(ctx.tools.get('ralph')).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).sections.some(candidate => candidate.name === 'tool:ralph')).toBe(false) + }) + + it('has the namespace-plugin export shape', () => { + expect('default' in toolRalph).toBe(false) + expect(toolRalph.name).toBe('tool-ralph') + expect(toolRalph.inject).toEqual(['tools', 'workflows', 'subagents', 'systemPrompt']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolRalph) as Record + expect(unwrapped).toBe(toolRalph) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/workflow/tool-ralph/tsconfig.json b/packages/workflow/tool-ralph/tsconfig.json new file mode 100644 index 0000000000..294a851a5b --- /dev/null +++ b/packages/workflow/tool-ralph/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../workflow" + } + ] +} diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index f7fcd6e6d2..23fa849888 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -39,7 +39,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide For each `agent()` call: 1. The worker sends `child-start` with a plain-data prompt and options. -2. The host calls the configured provider through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. +2. The host calls the start request's provider override, or otherwise the configured provider, through async `SubagentService.start`, passing the workflow's parent and one canonical per-run abort signal. Provider choice applies to every child in that run and is not visible to the script. 3. If start rejects, the host sends `child-start-error`; provider startup has already reached quiescence and no child lifecycle event is emitted. 4. If start fulfills while the workflow still admits work, the host records the run, observes `result`, then sends `child-started`. Even an already-settled result is forwarded afterward, preserving start-before-result order. 5. The worker emits paired `workflow/agent-start` and `workflow/agent-end` narration and requests child disposal after collection. @@ -81,6 +81,8 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th | `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. | | `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. | +An owning consumer may set `WorkflowStartRequest.subagentProvider` for one run. This is an engine-level route, not a script hook or a model-facing option; the ordinary `workflow` tool leaves it unset. + ## Model Experience ### Child-agent requests diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 9fed7d3450..604d8ba754 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -137,6 +137,7 @@ class WorkerWorkflowEngine extends WorkflowService { // the now-inactive engine fiber and break the seam's holder-owned lifetime. const runCtx = this.ctx const subagents = runCtx.subagents + const subagentProvider = request.subagentProvider ?? this.config.provider const workerRun = new WorkerRun( runCtx, subagents, @@ -144,7 +145,7 @@ class WorkerWorkflowEngine extends WorkflowService { meta, request.parent, init, - this.config.provider, + subagentProvider, this.config.disposeGraceMs, { phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) }, diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index e9d9a9b4e1..e330b3ee83 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -27,16 +27,30 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' const ctx = new Context() await ctx.plugin(SubagentService) -await ctx.plugin(WorkerWorkflowEngine, {}) +let selectedStarts = 0 +ctx.subagents.registerProvider({ + name: 'built-selected', + capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + async start() { + selectedStarts += 1 + return { + id: 'built-child', + result: Promise.resolve({ output: [], structured: { answer: 42 }, stopReason: 'completed' }), + dispose: () => Promise.resolve(), + } + }, +}) +await ctx.plugin(WorkerWorkflowEngine, { provider: 'must-not-be-used' }) const run = ctx.workflows.start({ - script: 'return 6 * 7', + script: "const value = await agent('answer', { schema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] } }); return value.answer", meta: { name: 'built-smoke', description: 'built worker smoke' }, - // A zero-agent script never touches the provider. + subagentProvider: 'built-selected', parent: { id: 'built-smoke-parent', options: {} }, }) const result = await run.result await run.dispose() -if (result.stopReason !== 'completed' || result.value !== 42) { +if (result.stopReason !== 'completed' || result.value !== 42 || selectedStarts !== 1) { console.error('unexpected result: ' + JSON.stringify(result)) process.exit(1) } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 2f52cd7b6b..e2cc227704 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -232,6 +232,26 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs[0]!.request.agentOptions).toEqual({ provider: 'openai' }) }) + it('a start-request provider override selects every child without changing the engine default', async () => { + const { ctx, parent, provider } = await setup() + const selected = new StubProvider('selected', () => text('selected reply')) + ctx.subagents.registerProvider(selected) + + const overridden = ctx.workflows.start({ + ...scripted("return await agent('route this run')"), + parent, + subagentProvider: 'selected', + }) + expect((await overridden.result).value).toBe('selected reply') + await overridden.dispose() + expect(selected.runs).toHaveLength(1) + expect(provider.runs).toHaveLength(0) + + const ordinary = await run(ctx, parent, scripted("return await agent('use the default')")) + expect(ordinary.value).toBe('stub reply') + expect(provider.runs).toHaveLength(1) + }) + it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])")) diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 9f2b557811..e21ebcd701 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -10,7 +10,7 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound. -`WorkflowStartRequest` contains `{ meta, script, args?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `meta` and `args` are plain data, not script fragments. +`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `meta` and `args` are plain data, not script fragments. `WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index faef18aeb0..de6af8f838 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -70,6 +70,12 @@ export interface WorkflowStartRequest { meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** + * Optional engine-wide child-provider override for this run. The workflow + * script cannot observe or replace it; omission uses the engine's configured + * provider. + */ + subagentProvider?: string /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a45c8b505..2ecae79efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:* version: link:../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:* + version: link:../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -2514,6 +2517,58 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/workflow/tool-ralph: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../../subagent/subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../workflow + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../workflow-workerthread + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/workflow/tool-workflow: dependencies: schemastery: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index bec93c790a..d271192516 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -262,8 +262,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subagent provider registry', mode: 'seam', implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], - consumers: ['tool-subagent'], - note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.', + consumers: ['tool-subagent', 'tool-ralph'], + note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.', }, { key: 'tasks', @@ -297,8 +297,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Workflow script engine', mode: 'seam', implementations: ['workflow-workerthread'], - consumers: ['tool-workflow'], - note: 'One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents.', + consumers: ['tool-workflow', 'tool-ralph'], + note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', }, ] diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 1921dbf27c..c1fe87eb04 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -37,6 +37,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') @@ -46,7 +47,7 @@ const OUT = 'docs/tool-catalog.md' function registerCatalogSubagentProvider(ctx: Context, name: string): void { const provider: SubagentProvider = { name, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), } @@ -195,6 +196,21 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.', }, + { + pkg: '@deepseek-ai/dsh-tool-ralph', + dir: 'tool-ralph', + source: 'packages/workflow/tool-ralph/src/index.ts', + requires: ['ctx.tools', 'ctx.workflows', 'ctx.subagents', 'ctx.systemPrompt', 'a calling Agent (exec.agent parents every fresh round)'], + writes: ['tool/call', 'tool/result', 'workflow and child session events during execution'], + async mount(ctx) { + await ctx.plugin(SubagentService) + registerCatalogSubagentProvider(ctx, 'mock') + await ctx.plugin(VmWorkflowEngine, { provider: 'mock' }) + await ctx.plugin(ToolRalph, { subagentProvider: 'mock' }) + }, + note: + 'A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/tsconfig.json b/tsconfig.json index 085c533130..cfda7192e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -106,6 +106,7 @@ { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, + { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, From e958c222a292807aafafb7815eff021f9ca49cad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:01:56 +0800 Subject: [PATCH 047/132] docs(agent-note): normalize goal command heading --- .../implemented/feature/2026-07-19-human-goal-command.i18n.yaml | 2 +- .../implemented/feature/2026-07-19-human-goal-command.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 14225a7d4f..e1a79533b9 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-19-human-goal-command.md: b195cd6d5d6e50672f2433e2f845e650d825a1f9 -2026-07-19-human-goal-command.zh.md: 11ffeb130e20f38c85e49c4109ece8913f3143ba +2026-07-19-human-goal-command.zh.md: 84a920c206b875d14349a69da556e945125b53c3 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 11ffeb130e..84a920c206 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -1,4 +1,4 @@ -# Agent Note:面向人类的 `/goal` 命令 +# Agent Note: 面向人类的 `/goal` 命令 Status: implemented From fffd8474ad988088824a1da07bdc2c3bba352cc6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:19:36 +0800 Subject: [PATCH 048/132] docs(agent-note): record implemented goal execution stack --- .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 126 +++++++ .../2026-07-16-harness-level-loop.zh.md | 126 +++++++ .../feature/2026-07-16-harness-level-loop.md | 343 ------------------ .../2026-07-16-harness-level-loop.zh.md | 343 ------------------ 5 files changed, 254 insertions(+), 688 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-07-16-harness-level-loop.i18n.yaml (65%) create mode 100644 .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md create mode 100644 .agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-07-16-harness-level-loop.md delete mode 100644 .agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml similarity index 65% rename from .agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 743d50cc5e..9f519419e7 100644 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 97041b1c08d5fb85a222824ee6d8dfd55e74f4bd -2026-07-16-harness-level-loop.zh.md: 6460254b07b0e185ff2a40c415d34bd25b6ad925 +2026-07-16-harness-level-loop.md: 76b4efbe42bd0938fcaff14e96ad4e73883e602d +2026-07-16-harness-level-loop.zh.md: 443501cfaf8a37a210786d4b251e58116c508ba0 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md new file mode 100644 index 0000000000..76b4efbe42 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -0,0 +1,126 @@ +# Agent Note: Harness-level goal-based execution + +Status: implemented + +English | [中文](2026-07-16-harness-level-loop.zh.md) + +## Problem + +The concrete agent loop owns one turn: it drains admitted input, performs one or more model-and-tool steps, and stops. Substantial objectives often need an outer policy that can begin another turn, retain progress, stop at a budget, and remain intelligible to humans. A timed prompt, a same-session continuation, and a fresh-agent Ralph attempt all repeat work, but they do not share the same state, authority, memory, or lifecycle. + +Treating every repeated action as one generic “loop” obscures those differences. Same-session work must persist the human objective in the existing transcript while preserving conversation context. Ralph work must intentionally discard conversation context and use the workspace plus a bounded handoff. Human-facing status must not imply that reopening a session silently authorizes more work. Completion and blocker claims also need an explicit trust boundary rather than being smuggled into a scheduler abstraction. + +The repository therefore needs goal-based execution above the turn/step loop, but it does not need a speculative universal loop service that combines persistence, evaluation, budgeting, scheduling, handoff, background tasks, and UI. + +## Decision + +This proposal is implemented in amended form as two explicit plugin policies over existing seams: + +1. **Same-session goals** retain one durable objective in the current session and admit goal-attributed continuation turns only while live activation is armed. +2. **Fresh-agent Ralph runs** execute a fixed foreground workflow whose rounds each spawn a new structured child with no conversation seed. + +There is no `packages/loop/` family, `LoopDriver`, `LoopId`, universal `StopCondition`, or model-facing generic `loop` tool. The two policies share the repository's ordinary agent, session, tools, workflow, subagent, and UI extension seams, but they do not pretend that one lifecycle fits both. + +### Vocabulary and policy boundary + +The same-session hierarchy is **Goal → Goal Round → Turn → Step**. A goal round is one continuation cycle admitted for the current goal and materialized as one goal-sourced turn. Human or unrelated turns in the same session do not consume the goal-round cap, and a turn may still contain multiple model/tool steps. + +The fresh-agent hierarchy is **Ralph Run → Ralph Round → fresh child Turn → Step**. One Ralph round creates one child session. The parent transcript and prior child transcripts are not seed context; the shared workspace and one bounded structured report carry cross-round state. + +“Round” is therefore an outer policy iteration, not a synonym for every session turn. The concrete `dsh-agent-loop` remains the turn/step engine. The same-session driver uses public agent and session events; its only core addition is the generic observe-before-cancel `agent/cancel-requested` notification needed by any lifecycle policy that must settle cancellation safely. + +Time-based `/loop` or scheduled execution is a third policy and is not implemented by this decision. It belongs with a scheduler rather than either goal family. + +### Package topology and owning verbs + +| Package | Repository category | Owned structures and verbs | +|---|---|---| +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, `GoalPhase`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `markUsageLimited`, `markBudgetLimited`, `clear`, and `disarm` verbs. | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports. | +| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`, continuation policy | Reserves, fences, admits, attributes, settles, cancels, and quiescently drains same-session goal rounds without importing the concrete loop. | +| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`, UI registry | Owns `CommandDefinition`, discovery, scoped registration, direct dispatch, `CommandResult`, and request cancellation for human-only commands. | +| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI and ACP. | +| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`, fixed workflow consumer | Registers `ralph({ objective, maxRounds? })`, validates the fresh structured provider and bounded `RalphRoundReport`, and returns `complete`, `blocked`, or `budget-limited`. | + +The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-session-goal-domain.md), [model goal-tools](2026-07-19-model-facing-goal-tools.md), [goal-round driver](2026-07-19-same-session-goal-round-driver.md), [command registry](2026-07-19-plugin-command-registration.md), [human goal-command](2026-07-19-human-goal-command.md), and [Ralph workflow-tool](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Notes. + +### Durable goal state and live authority + +One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. + +Durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. + +This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption. + +Forked sessions inherit the durable goal prefix because that is the natural replay result. The fork starts disarmed, so inheritance does not imply execution authority and no synthetic goal cancellation is inserted into history. + +`defaultMaxGoalRounds` is configurable and defaults to `256`. The cap counts only admitted goal rounds. `blockedAfterConsecutiveRounds` is separately configurable in the model-tool policy and defaults to `3`; it is a mechanical lower bound before an autonomous round may report a repeated blocker, not an evaluator of semantic sameness. + +### Same-session continuation + +The goal-round driver owns at most one pending reservation per exact live agent. It admits a reservation only when the goal is active and armed, the agent is idle, no competing human work exists, pending mutations are durable, the exact goal id/revision/round still matches, and downstream prompt policy accepts it. The prompt-submit fence checks those facts both before and after asynchronous listeners, preventing an edit, pause, human message, or unload race from admitting obsolete work. + +Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round. + +Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses; rate limiting records `usage-limited`; cap exhaustion records `budget-limited`; other errors, max-token stops, policy rejections, and unknown terminal results block for inspection. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`. + +### Human and model surfaces + +The human UX follows the compact current [Codex `/goal` command shape](https://learn.chatgpt.com/docs/developer-commands?surface=cli): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them. + +The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. + +TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted. + +### Fresh-agent Ralph execution + +Ralph is a first-class model tool in its own plugin, demonstrating that a sophisticated fixed execution policy can be composed without a new loop core. The plugin owns a fixed workflow script over `ctx.workflows` and `ctx.subagents`; it does not create session-goal state or add a branch to `dsh-agent-loop`. + +Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report. + +A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated. + +The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded terminal result; intermediate child conversations remain outside the parent transcript. + +### External design lineage + +Codex provides the minimal observable goal UX used here: a persistent chat-attached target with set, view, edit, pause, resume, and clear controls. This implementation adopts that discoverability while using this repository's event-sourced goal record, plugin scopes, and runtime authority checks. + +Current [Claude Code goals](https://code.claude.com/docs/en/goal) reinforce the distinction between a goal that starts another turn after the previous turn and a timed `/loop`. Claude Code also uses a separate small-model evaluator after each turn. This implementation adopts the policy distinction but intentionally does not copy that evaluator: evaluator inputs, tool access, deterministic checks, provider choice, isolation, and authority need a separately designed plugin contract rather than an implicit self-certification layer. + +External products are comparators, not compatibility targets. The local source studies informed the boundaries, while the shipped interfaces follow this repository's “everything is a plugin”, model-visible-is-logged, explicit default resolution, and quiescent teardown rules. + +### Verification + +The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, ACP transcript isolation, fixed Ralph provider routing, distinct unseeded children, bounded handoff, terminal outcomes, and cancellation quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests. + +## Alternatives considered + +- **Implement the original universal loop capability seam** — rejected because `Evaluator`, `BudgetPolicy`, `RoundHandoff`, `GoalReflector`, background task ownership, persistence, and scheduling do not form one coherent mandatory abstraction. Building all of them before their first concrete consumers would create broad speculative surface and duplicate existing session, workflow, subagent, and task machinery. +- **Implement only same-session goals** — rejected because fresh-context iteration is materially different and is a valuable demonstration of the plugin architecture. Ralph belongs as a fixed workflow consumer with explicit context reset. +- **Put Ralph inside the goal-round driver** — rejected because same-session goals deliberately preserve one conversation while Ralph deliberately removes it. Combining them would make activation, replay, handoff, and UI state ambiguous. +- **Treat a fork as a fresh Ralph child** — rejected because a fork carries a conversation prefix. Fresh children plus workspace state and one explicit report are easier to bound and replay without a synthetic cancel record. +- **Copy Claude Code's evaluator into the first goal implementation** — rejected because a transcript-only model evaluator is one useful policy, not a generally trustworthy completion certificate. Deterministic evaluation and isolation must remain possible, so the evaluator is deferred until its authority and provider seam are designed. +- **Automatically continue after session restore** — rejected because opening a session is observation, not authority to spend resources. Durable state is restored while activation waits for a new human prompt. +- **Route `/goal` through the model** — rejected because status and explicit lifecycle controls should be deterministic, token-free UI actions; ordinary natural-language prompts remain the semantic model path. +- **Modify the concrete agent loop with goal or Ralph modes** — rejected because public queue, prompt, session, cancellation, workflow, and subagent seams already support both policies. The generic cancel-requested observation is the only core coordination addition. + +## Consequences + +- Goal-based execution ships without one overloaded “loop” object: same-session continuation and fresh-agent iteration have explicit, separately testable contracts. +- Durable goal history is replayable and forkable, while process-local activation prevents accidental work on resume. +- Humans receive a small Codex-shaped UX; models receive a compact provenance-checked tool surface; deployments can remove either independently. +- Ralph demonstrates a nontrivial fixed policy entirely as a plugin over existing workflow and subagent primitives. +- Round limits are generous by default but remain deployment-controlled. They bound iterations, not tokens, price, elapsed time, or external side effects. +- The original proposal's evaluator, budget, reflector, background-task, CLI, and generic loop-session architecture is intentionally not part of the implemented public surface. + +## Known limitations and deferred work + +- **Independent evaluation** — same-session completion/blocking and Ralph terminal status are model or worker declarations. A separate evaluator, evaluator-driven feedback round, completion certificate, deterministic checker, adversarial verifier, and criteria/executor/isolation contract remain deferred. +- **Aggregate budgets** — `maxGoalRounds` and Ralph `maxRounds` are the only aggregate effort limits. Token, currency, elapsed-time, provider-usage, and per-round price admission policies are absent. +- **No persistent autonomous runner** — same-session goal facts persist, but activation and scheduling are process-local and deliberately wait for human input after restore. Ralph runs are foreground and cannot resume after process loss. Background collection, restart recovery, and unattended resident execution are deferred. +- **No time scheduler** — interval `/loop`, cron, proactive maintenance, and cloud or desktop scheduling are outside this decision. +- **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. +- **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. +- **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. +- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md new file mode 100644 index 0000000000..443501cfaf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -0,0 +1,126 @@ +# Agent Note: Harness 层目标式执行 + +Status: implemented + +[English](2026-07-16-harness-level-loop.md) | 中文 + +## 问题 + +具体 agent loop 只拥有一个 Turn:它排空已接纳输入,执行一个或多个模型与工具 Step,然后停止。大型目标通常需要一项外层策略来开始另一个 Turn、保留进度、在预算处停止,并让人类能够理解其状态。定时提示词、同会话续行和全新 agent Ralph 尝试都会重复工作,但它们并不共享相同的状态、权限、记忆或生命周期。 + +若把每种重复动作都称为一个通用“loop”,就会掩盖这些差异。同会话工作必须在现有转录中持久化人类目标,同时保留对话上下文。Ralph 工作必须有意丢弃对话上下文,只使用工作区和一份有界交接。面向人类的状态不能暗示重新打开会话就会静默授权更多工作。完成与阻塞声明也需要显式信任边界,而不能被偷渡进调度器抽象。 + +因此,本仓库需要位于 Turn/Step loop 之上的目标式执行,但不需要一个把持久化、评估、预算、调度、交接、后台任务和 UI 组合在一起的推测性通用 loop 服务。 + +## 决策 + +本提案以修订后的形式实现为构建在现有接缝之上的两项显式插件策略: + +1. **同会话目标**在当前会话中保留一个持久目标,并且只在实时激活态已激活时接纳带目标归属的续行 Turn。 +2. **全新 agent Ralph 运行**执行一个固定前台工作流,其中每个 Round 都生成一个不带对话种子的全新结构化子 agent。 + +系统中没有 `packages/loop/` 包族、`LoopDriver`、`LoopId`、通用 `StopCondition` 或面向模型的通用 `loop` 工具。两项策略共享本仓库普通的 agent、session、tools、workflow、subagent 与 UI 扩展接缝,但不会假装一种生命周期可以同时适配两者。 + +### 词汇与策略边界 + +同会话层级是 **Goal → Goal Round → Turn → Step**。一个 Goal Round 是为当前目标接纳的一次续行周期,并实体化为一个带目标来源的 Turn。同一会话中的人类 Turn 或无关 Turn 不会消耗目标回合上限,而一个 Turn 仍可包含多个模型/工具 Step。 + +全新 agent 层级是 **Ralph Run → Ralph Round → fresh child Turn → Step**。一个 Ralph Round 创建一个子会话。父转录和此前子转录都不是种子上下文;共享工作区与一份有界结构化报告承载跨 Round 状态。 + +因此,“Round”是外层策略迭代,不是每个会话 Turn 的同义词。具体 `dsh-agent-loop` 仍是 Turn/Step 引擎。同会话驱动器使用公开 agent 与 session 事件;它对核心唯一的新增项是通用的取消前观察通知 `agent/cancel-requested`,任何需要安全收敛取消的生命周期策略都可以使用它。 + +基于时间的 `/loop` 或定时执行是第三种策略,本决策不实现它。它应归属于调度器,而不是任一目标包族。 + +### 包拓扑与所属动词 + +| 包 | 仓库类别 | 所属结构与动词 | +|---|---|---| +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、`GoalPhase`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`markUsageLimited`、`markBudgetLimited`、`clear` 与 `disarm` 动词。 | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到完成或阻塞报告。 | +| `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 | +| `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 | +| `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 和 ACP 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 | +| `@deepseek-ai/dsh-tool-ralph` | `packages/workflow/tool-ralph/`,固定工作流消费者 | 注册 `ralph({ objective, maxRounds? })`,验证全新结构化 provider 与有界 `RalphRoundReport`,并返回 `complete`、`blocked` 或 `budget-limited`。 | + +详细契约分别由[目标领域](2026-07-19-persisted-same-session-goal-domain.md)、[模型目标工具](2026-07-19-model-facing-goal-tools.md)、[目标回合驱动器](2026-07-19-same-session-goal-round-driver.md)、[命令注册表](2026-07-19-plugin-command-registration.md)、[人类目标命令](2026-07-19-human-goal-command.md)与 [Ralph 工作流工具](2026-07-19-fresh-agent-ralph-workflow-tool.md) Agent Note 拥有。 + +### 持久目标状态与实时权限 + +一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。 + +持久阶段为 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 与 `complete`。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 + +这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。 + +fork 会话会继承持久目标前缀,因为这是自然的重放结果。fork 从未激活状态开始,因此继承不等于执行权限,历史中也不会插入合成目标取消。 + +`defaultMaxGoalRounds` 可配置且默认为 `256`。该上限只计算已接纳目标回合。`blockedAfterConsecutiveRounds` 在模型工具策略中单独配置且默认为 `3`;它只是在自治 Round 报告重复阻塞前的机械下限,不是对语义相同性的评估器。 + +### 同会话续行 + +目标回合驱动器为每个准确实时 agent 至多拥有一个待定预留。只有目标处于活跃且已激活状态、agent 空闲、不存在竞争性人类工作、待定变更已经持久、准确目标 id/修订号/Round 仍匹配,并且下游提示词策略接受时,它才会接纳预留。prompt-submit 围栏在异步监听器前后都检查这些事实,防止编辑、暂停、人类消息或卸载竞争接纳过时工作。 + +只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。 + +普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停;速率限制记录 `usage-limited`;上限耗尽记录 `budget-limited`;其他错误、max-token 停止、策略拒绝和未知终止结果会进入阻塞状态以供检查。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 + +### 人类与模型表面 + +人类 UX 遵循当前紧凑的 [Codex `/goal` 命令形态](https://learn.chatgpt.com/docs/developer-commands?surface=cli):`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。 + +模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 + +TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。 + +### 全新 agent Ralph 执行 + +Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。 + +每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 + +报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。 + +该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用和一份有界终止结果;中间子 agent 对话不会进入父转录。 + +### 外部设计谱系 + +Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天的持久目标,以及设置、查看、编辑、暂停、恢复与清除控制。本实现采用这种可发现性,但使用本仓库的事件溯源目标记录、插件作用域与运行时权限检查。 + +当前 [Claude Code goals](https://code.claude.com/docs/en/goal) 进一步验证了“前一 Turn 后启动另一 Turn 的目标”和定时 `/loop` 之间的区别。Claude Code 还会在每个 Turn 后使用独立小模型评估器。本实现采用策略区分,但有意不复制该评估器:评估器输入、工具访问、确定性检查、provider 选择、隔离与权限需要单独设计的插件契约,而不是隐式自我认证层。 + +外部产品只是比较对象,不是兼容目标。本地源码研究帮助确定边界,而交付接口遵循本仓库“一切皆插件”、模型可见即可记录、显式解析默认值与静止拆卸规则。 + +### 验证 + +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现、ACP 转录隔离、固定 Ralph provider 路由、互不相同且无种子的子 agent、有界交接、终止结果与取消静止性。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。 + +## 考虑过的替代方案 + +- **实现原始通用 loop 能力接缝**——不予采纳,因为 `Evaluator`、`BudgetPolicy`、`RoundHandoff`、`GoalReflector`、后台任务所有权、持久化与调度并不构成一项一致的必选抽象。在出现首个具体消费者前全部构建,会产生宽泛推测性表面,并重复现有 session、workflow、subagent 与 task 机制。 +- **只实现同会话目标**——不予采纳,因为全新上下文迭代在实质上不同,也是插件架构的重要示范。Ralph 应作为带显式上下文重置的固定工作流消费者。 +- **把 Ralph 放进目标回合驱动器**——不予采纳,因为同会话目标有意保留一段对话,而 Ralph 有意移除对话。合并两者会让激活、重放、交接与 UI 状态含糊不清。 +- **把 fork 当成全新 Ralph 子 agent**——不予采纳,因为 fork 会携带对话前缀。全新子 agent 加工作区状态与一份显式报告更容易限制和重放,并且无需合成取消记录。 +- **把 Claude Code 评估器复制进首个目标实现**——不予采纳,因为只读取转录的模型评估器是一项有用策略,但不是普遍可信的完成证书。系统必须仍能支持确定性评估与隔离,因此评估器延期到其权限与 provider 接缝完成设计之后。 +- **会话恢复后自动续行**——不予采纳,因为打开会话是观察行为,不是花费资源的权限。系统恢复持久状态,而激活态等待新的人类提示词。 +- **通过模型路由 `/goal`**——不予采纳,因为状态与显式生命周期控制应是确定、零 token 的 UI 操作;普通自然语言提示词仍是语义模型路径。 +- **为具体 agent loop 增加目标或 Ralph 模式**——不予采纳,因为公开队列、提示词、会话、取消、工作流与 subagent 接缝已经支持两项策略。通用 cancel-requested 观察是唯一核心协调新增项。 + +## 后果 + +- 目标式执行在没有单个过载“loop”对象的情况下交付:同会话续行与全新 agent 迭代拥有显式、可独立测试的契约。 +- 持久目标历史可以重放和 fork,而进程本地激活态会防止恢复时意外开始工作。 +- 人类获得小型 Codex 形态 UX;模型获得紧凑、带来源检查的工具表面;部署可以独立移除任一能力。 +- Ralph 展示了非平凡固定策略可以完全作为现有 workflow 与 subagent 原语之上的插件实现。 +- Round 上限默认宽裕,但仍由部署控制。它限制迭代次数,不限制 token、价格、耗时或外部副作用。 +- 原始提案中的评估器、预算、反思器、后台任务、CLI 与通用 loop-session 架构有意不进入已实现公开表面。 + +## 已知限制与延期工作 + +- **独立评估**——同会话完成/阻塞和 Ralph 终止状态都是模型或工作者声明。独立评估器、评估器驱动反馈 Round、完成证书、确定性检查器、对抗式 verifier 与 criteria/executor/isolation 契约均予以延期。 +- **聚合预算**——`maxGoalRounds` 与 Ralph `maxRounds` 是唯一聚合工作量限制。token、货币、耗时、provider 用量与逐 Round 价格准入策略均不存在。 +- **没有持久自治运行器**——同会话目标事实会持久化,但激活与调度只存在于进程内,并且有意在恢复后等待人类输入。Ralph 位于前台,进程丢失后无法恢复。后台收集、重启恢复与无人值守常驻执行均予以延期。 +- **没有时间调度器**——间隔 `/loop`、cron、主动维护以及云端或桌面调度不在本决策范围内。 +- **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 +- **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 +- **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 +- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。 diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md deleted file mode 100644 index 97041b1c08..0000000000 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.md +++ /dev/null @@ -1,343 +0,0 @@ -# Agent Note: harness-level goal-based loop - -Status: proposed - -English | [中文](2026-07-16-harness-level-loop.zh.md) - -## Problem - -`packages/core/agent-loop` runs only the inner loop: reasoning plus tool calls within one turn, ending when the model returns `end_turn`. Its README explicitly writes "No built-in turn budget"—budget is a gap it acknowledges itself. Cross-round scheduling falls on the harness layer: iterating until tests all pass, revising drafts against a rubric, splitting a PRD into beads and driving them one by one, running unattended for a whole night. None of these tasks has a first-class implementation today. - -The existing code offers three "just enough to run" alternatives, none of them adequate: - -| Alternative | Problem | -|---|---| -| A `packages/workflow` script expressing `while (!done)` | The README explicitly writes "No token-budget vocabulary" and "No journaling or resume"; the parent turn blocks until the script settles. Fine for orchestration lasting minutes, unusable for tasks lasting hours | -| An external shell `while :; do dsh-sdk …; done` | Ralph-style scheduling can be written this way today. It lacks a shared vocabulary for stop condition, budget, and evaluator, so every user reinvents them; the loop itself has no durable object for post-hoc diagnosis or recovery | -| The `sendMessage`/`resume` capabilities on the `packages/subagent` seam | The README explicitly writes "Runtime steering and continuation are seam-only capabilities". There is no model-facing consumer, so the model can only start a fresh subagent | - -Three typical use cases. **Automated fix**: a failing test suite in front of you, and you want a process to keep modifying code, running tests, and modifying again against the failure messages, until everything is green or the budget cap is hit. **Rubric-driven iterative revision**: a document, code, or translation must meet a set of scoring criteria; the loop repeatedly adjusts, an independent evaluator scores, and the loop stops when the criteria are met or the round budget is exhausted. **Unattended long runs**: for example, porting a repository from one tech stack to another overnight, kicked off before leaving work and reviewed the next morning, with the budget as the only safety net. Common shape across all three: minutes to hours, evaluator decides success, budget is a hard constraint, and post-run review and recovery are required. - -## Proposal - -**Loops come in four trigger shapes**, distinguished by who starts a round and when: - -| Shape | Who triggers | When | Existing comparable | This RFC | -|---|---|---|---|---| -| **turn-based** | The user sends a message in the session | Every user reply | `packages/core/agent-loop`'s existing reasoning-plus-tools cycle within one turn | Not covered; already implemented | -| **goal-based** | The user or the agent specifies "run until some condition" | One start, evaluator decides when to stop | Claude Code's `/goal`, Codex's `/goal`, the Ralph family | **This RFC covers it** | -| **time-based** | A scheduler | On cron or fixed interval | Claude Code's `/loop` (periodic), `/schedule` | Deferred to a `dsh-schedule` RFC | -| **proactive** | The agent itself | When the agent realizes during reasoning that a loop is needed | The proactive tier in Anthropic ClaudeDevs's four-way taxonomy | **Naturally included** (an agent calling the `loop` tool is already proactive) | - -This RFC only **adds a capability seam `packages/loop/`** for the goal-based shape. Proactive reuses the same `loop` tool—an agent invocation is a trigger by itself, with no extra machinery. Time-based needs an independent scheduler package and belongs to a separate RFC; this RFC only reserves a hook on the cordis leaf trigger surface for the future `dsh-schedule` integration. - -Three packages: - -- `@deepseek-ai/dsh-loop`: types, the `LoopDriver` service, the `StopCondition` discriminated union, the Phase 1 service definitions (`Evaluator` / `BudgetPolicy` / `RoundHandoff`), and the event schema; `GoalReflector` joins in Phase 2 with its first caller -- `@deepseek-ai/dsh-loop-driver`: the default driver implementation -- `@deepseek-ai/dsh-loop-tool`: the model-facing `loop` tool plus the CLI `dsh-sdk loop` - -The design is organized around four concrete problems, addressed by service seams or explicit driver policies in the phase where each has a caller: - -1. A long-running loop that goes wrong leaves no systematic diagnosis or recovery. **Loop as an independent session** addresses this. -2. Whether the PASS at loop end is trustworthy determines whether hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face. **Making Evaluator and Budget into service seams** addresses this. -3. Short and long tasks need opposite memory strategies; hardcoding one mode makes the other class of scenario unusable. **Making RoundHandoff into a service seam** addresses this. -4. The user's initial goal is not always correct. An agent stubbornly pursuing a wrong goal exhausts the budget doing wrong work. **Goal concern events and policies cover Phase 1; the GoalReflector service arrives with the Phase 2 `reflect` path**. - -Beyond the four seams, one principle threads through the whole document: **one loop handles one atomic goal**. Large goals should be split into several small loops chained in sequence, not stuffed into one loop with the evaluator judging multiple things. A rule of thumb for whether granularity is right: if you cannot say what a finished loop actually accomplished, granularity is too large and should be split. Phase 2 adds a `loop_split` model-facing tool so the agent can split an oversized goal itself. - -Terminology: **inner loop** refers to the existing per-turn reasoning-and-tools cycle in `packages/core/agent-loop`; **harness loop** refers to the outer scheduler introduced by this RFC, iterating around the inner loop. This RFC does not modify `agent-loop`, matching AGENTS.md's "Plugins, not loop changes". - -`StopCondition` is a discriminated union with `assertNever` closing the switch: - -```ts -interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - -type StopCondition = - | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } - | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } - | { kind: 'approval-required'; reason: string } - | { kind: 'user-cancel' } - -export {} -``` - -### Loop as an independent session - -Once a long-running loop goes wrong, the user has no systematic diagnostic method. A failure hours in leaves only scattered log files to sift through. Discovering that some middle round went off track and wanting to roll back to re-run means starting over from scratch. An agent wanting to consult its own experience from past loops has no API to reach it. - -The driver opens an independent loop-session (a new session id) for each loop. Every round's inputs, inner-loop results, evaluator reports, and stop decisions are persisted as session events, reusing the SQLite backend from `packages/session-persistence`. This yields three diagnostic and replay capabilities. - -- **Replay conversation from a recorded round**: while the source session is live, discover round 78 went off and fork the round-77 event prefix with a different prompt or evaluator; persisted replay needs a separate trusted load-and-seed path. Both forms replay conversation state against the current workspace, not the files and external side effects that existed at round 77 -- **Post-hoc diagnosis**: through the existing `ctx.sessionQuery` exact-read service, inspect the round where the evaluator started hanging on the same criterion -- **Meta-loop learning**: the proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) can later find related historical loops before a new run—"have I fixed a similar bug before? Which round did it fail on?" - -Claude Code's and Codex's `/goal` are one-off objects: discarded when the run ends, so the agent starts from zero when facing a similar problem again. - -**Storage and recovery boundary**. A few KB of events per round, roughly 100–500 KB per 100-round loop; thousands of loops reach GB scale. Mitigated by the `logDetail: 'summary' | 'full'` config, defaulting to `full` with long-run users able to switch to `summary`. Persisting all intermediate state also writes generated keys, passwords, and similar secrets to disk—the same class of risk as an ordinary session but amplified 10–100×, and the README calls this out clearly. Exact live and persisted reads already exist through `ctx.sessionQuery`; FTS5 is an optional discovery improvement, not a Phase 1 dependency. Exact execution-world restore is not promised: `SessionStore.fork()` accepts a live session, and session events do not restore files, processes, environment, or external side effects. Restoring those requires a separate Git/worktree/checkpoint design. - -### Pluggable Evaluator and Budget - -A loop's value ultimately depends on whether the final PASS is trustworthy. If the evaluator can be hacked or hallucinates PASS, hours of work are wasted. An architecture where the same LLM both generates and self-evaluates is not trustworthy on its face: the model has the means to talk itself into PASS. Even letting an independent subagent be the evaluator only mitigates the problem; as long as the evaluator is still an LLM, it retains a systematic bias for the same class of content—an independent subagent is a mitigation, not a cure. - -Trustworthy evaluation needs both a deterministic judgment mechanism and an isolation boundary appropriate to the threat model: shell exit code, static analysis, or an external service avoids LLM self-judgment, while a separate worktree, read-only mount, container, or remote service prevents the worker from rewriting evaluator inputs. Only the user knows which checks and boundary to use: `pytest` commands differ by project, companies have private compliance checkers, and some teams also run internal lint. No number of built-in evaluators can cover them all. Evaluator therefore must be a seam the user can plug into. - -Budget is the same story: product-level spending guardrails are opaque, and cannot be adjusted for team policy (personal card, team splitting, per-PR settlement). - -`Evaluator` and `BudgetPolicy` are both exposed as cordis service seams, with users injecting them as plugins. `Goal` must carry an `EvaluatorSpec` at an explicit tier; the driver refuses to start a loop without a paired evaluator—vague goals ("write good code") cannot enter the loop system: - -```ts -interface RubricItem { name: string; description: string } -interface EvaluatorContract { readonly name: string } - -type CriteriaSpec = - | { kind: 'single-metric'; name: string } - | { kind: 'rubric'; criteria: RubricItem[] } - | { kind: 'contract'; interface: EvaluatorContract } - -type ExecutorSpec = - | { kind: 'shell'; command: string } - | { kind: 'llm-judge'; rubric: string; model: string } - | { kind: 'provider'; name: string; config?: unknown } - -type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' - -interface EvaluatorSpec { - criteria: CriteriaSpec - executor: ExecutorSpec - isolation: IsolationSpec -} - -export {} -``` - -**Why explicit dimensions instead of letting the user pass any function?** The spec forces the user, at start time, to declare what is judged, what executes the judgment, and what isolation boundary protects it. A free function looks flexible, but in practice it lets evaluator strength quietly regress—the user thinks they're doing a deterministic isolated check when they've actually written a same-workspace LLM judgment. In long-run scenarios the cost is hours wasted. - -Criteria shape, executor, and isolation are orthogonal rather than a trust ladder: a rubric may be checked by shell, an LLM, or an external service, and a contract may run in the same workspace or in a container. `llm-judge` remains the weakest executor for soft goals where there truly is no hard signal (revision, translation quality). The documentation explicitly notes "this executor cannot defend against targeted adversarial input; long unattended runs require at least one deterministic evaluator with an isolation boundary appropriate to the threat model". - -The driver enforces four structural constraints, not delegated to Evaluator implementations. Isolation strength remains an explicit property of the configured provider rather than a claim the driver can manufacture. - -**Preventing "the same agent both generates and self-evaluates"**: - -1. **fresh subagent for LLM evaluation**: an LLM evaluator opens a fresh subagent (via `dsh-subagent`'s `spawn`) each round, not inheriting the main loop's context - -**Preventing the evaluator subagent itself from being subverted**: - -2. **scoped tool set**: an LLM evaluator's model-facing tool set is stripped by the driver to reads only, with write tools disabled (reusing `SubagentProvider`'s `toolFilter`). This reduces accidental mutation but is not process isolation: shell, code runtimes, or another capability can still write unless the configured isolation boundary prevents it - -**Preventing the evaluator report itself from deceiving the driver**: - -3. **PASS can only flip via an evaluator report**: the `goal-met` StopCondition can only come from the evaluator; the driver and the main agent cannot construct it directly -4. **Default-FAIL**: the driver maintains each criterion's pass state at `false` internally; only an evaluator report with non-empty evidence is allowed to flip it to `true`. The evaluator cannot get the driver to accept a `{pass: true}` return with no evidence - -Together, the four ensure that evaluator conclusions are structurally evidence-driven rather than confidence-driven. They do not stop the main agent from modifying evaluator inputs in a shared workspace. - -**Phase 1 ships three backends**: - -- `loop-evaluator-shell` implements `single-metric`: runs a shell command, `exit 0` = pass -- `loop-evaluator-rubric-judge` implements `llm-judge`: a prewritten rubric plus LLM scoring, soft goals only -- `loop-budget-preflight`: before each round starts, estimate `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`; refuse to start if the estimate exceeds `perRoundUsd`. The estimation model comes from MartinLoop `policy.ts:551-596` - -A `PricingProvider` service injects the pricing table; a test seam can override it, and it is not hardcoded into the driver (AGENTS.md "No hardcoded tunables in plugins"). A resolved budget carries `maxRounds`, optional `maxTokens` and `maxUsd`, and optional `perRoundUsd`. The driver checks per-round admission before starting work, then accumulates worker, evaluator, compaction, and reflector usage after every request. A token or USD cap may overrun by one in-flight request because usage arrives after completion; the `budget-cap` result reports `observed` and `maximum`. The `rubric` and `contract` criteria shapes get built-in executors in Phase 2; Phase 1 exposes the shapes so third-party plugins can integrate first. - -**Limitation**: `same-workspace` plus a read-only model-facing tool set is not hard isolation. The current `packages/fs` policy enforces read-before-edit and version guards, not path denial, and bash or code runtimes can bypass filesystem tools. Defending against targeted adversarial input requires a boundary across every mutation channel—such as a read-only mount, isolated worktree, container, or remote evaluator. The two-container approach (the evaluator's definition files are entirely inaccessible to the main agent, the route Anthropic patch.py takes) remains a Phase 3 item. See Risks. - -### Pluggable RoundHandoff - -How context passes between rounds is a dilemma. Preserving the full prior conversation (continue) reads more coherently, but the conversation keeps growing and eventually hits the context ceiling, and errors from a prior round contaminate every subsequent round. Starting each round from scratch (fresh) avoids the contamination, but has to re-understand context every time. A 3-round revision loop and an 80-round overnight bug-fix loop need opposite strategies. Claude Code and Codex both hardcode one mode, so users cannot switch by task type. - -Made a service seam: - -```ts -interface ContinuationRun { - readonly id: string - resume?(prompt: string): Promise -} - -interface PreviousRound { - result: unknown - evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - tokenUsage: number - summary: string - sessionId: string - run?: ContinuationRun -} - -interface RoundContext { loopId: string; round: number; previous: PreviousRound } - -type NextRoundSpec = - | { mode: 'fresh'; prompt: string } - | { mode: 'continue'; run: ContinuationRun; prompt: string } - -interface RoundHandoff { - buildNextRound(prev: RoundContext, signal: AbortSignal): Promise -} - -export {} -``` - -Phase 1 ships the fresh backend; Phase 2 adds the two continuation backends after provider continuation exists: - -| Backend | Phase | Scenario | Mechanism | -|---|---|---|---| -| `handoff-fresh-with-summary` (default) | Phase 1 | Long runs, unattended | Open a fresh subagent each round, injecting only a progress summary as a system prompt append | -| `handoff-continue-with-compaction` (recommended middle) | Phase 2 | Medium length, 5–20 rounds | Retain the full conversation up to a token threshold; over the threshold, reuse [`packages/compact`](../../../../packages/compact/README.md) to compress into a summary, using summary + last K rounds as the starting point | -| `handoff-continue-raw` (advanced) | Phase 2 | ≤5 rounds, short tasks, testing | Plain continuation without truncation | - -**Why default to fresh?** Every long-run loop that actually succeeded (repomirror, Kimi ralph-loop, autoresearch) uses fresh. Placing important loop state outside the context window under driver management is the correct posture for long runs. `handoff-continue-raw` violates this experience, and the README explicitly notes it is not suitable for long runs. - -**Why is only this repo able to build the middle tier?** `handoff-continue-with-compaction` depends on a compaction seam—the competitors don't have one; only this repo's `packages/compact` provides that infrastructure. - -**Why a seam rather than a three-choice flag?** Users can write 20-line plugins expressing hybrid strategies like "continue for the first 5 rounds, then fresh", or "auto-compact once when context hits 50%", without waiting for main-library support. - -**Limitation**: `continue-with-compaction` depends on the compression quality of `packages/compact`; compression itself may write hallucinated information into the summary and propagate it forward. The README recommends fresh for long runs. The three backends' boundaries may confuse new users about which to pick; the `dsh-sdk loop` CLI defaults to fresh, so users don't have to understand the differences before hitting a concrete problem. - -### Pluggable GoalReflector - -The goal the user gives at loop start is not always accurate. It may be based on a wrong assumption (asking the agent to implement a feature with a since-deprecated API), it may not be clear enough (the agent discovers a clarification is needed only mid-work), or it may be invalidated by later information. Current loop-execution frameworks treat the goal as a contract frozen at start; the agent can only push down the original path, and the result is exhausting the budget on the wrong direction. - -Phase 2 makes this a service seam, with responsibility separated from `Evaluator`: the evaluator asks "did we reach the goal", the reflector asks "is the goal still the same goal". Phase 1 carries concern events plus the `stop` and `notify-continue` driver policies without registering an unused `GoalReflector` service. - -```ts -interface RoundContext { loopId: string; round: number } -interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } - -interface GoalReflector { - reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise -} - -type GoalReflection = - | { kind: 'continue' } // goal 仍有效 - | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal - | { kind: 'stop-for-human'; reason: string } // 需要人拍板 - -export {} -``` - -**Concerns have three sources**. Phase 1 ships the first two; the `GoalReflector` service and periodic source arrive together in Phase 2: - -- **Agent-initiated**: via the model-facing tool `loop_flag_concern({ concern, severity })`. An agent that realizes during investigation that "the library the user assumed has been deprecated" can raise directly -- **Driver heuristic**: when budget passes 50% and zero criteria have passed, the driver auto-raises a `no-progress-toward-goal` concern -- **Periodic reflector subagent** (Phase 2): every N rounds, run an independent read-only subagent to re-audit goal validity, following the same isolation approach as the evaluator - -**Response strategy is controlled by the `onGoalConcern` config**. The four settings correspond to different philosophies about loop use; users choose by their team's collaboration style, and the driver takes no default stance: - -- `'stop'` (Phase 1 default): any concern triggers `StopCondition: approval-required`, and a human decides. A loop should never proceed on its own in the face of uncertainty—suitable for cautious teams and for high-impact loop scenarios -- `'notify-continue'` (Phase 1): record an ordinary `loop/goal-concern` session event, then continue; a human reviews at the end. ACP has no general high-priority marker, so dedicated concern rendering is deferred with the ACP command infrastructure. The loop internal is not interrupted—suitable for unattended long runs -- `'reflect'` (Phase 2): call `GoalReflector` to decide continue, revise, or stop. Delegates the initial judgment to an independent agent in place of a human—suitable for teams with moderate autonomy -- Not registering a `GoalReflector` and leaving `onGoalConcern` unset = the most hands-off tier: the loop stops only on traditional stop conditions - -**Why default to `stop`?** In unattended scenarios, stopping one extra time is safer than running for hours in the wrong direction. Users who explicitly want unattended can switch to `notify-continue`. - -A concern is itself just an ordinary session event, composing naturally with the persistence capability described earlier: a later replay can seed a new conversation from the round where the concern surfaced and swap the goal. This does not roll the workspace back to that round. - -**Abuse and loss protection**. An agent could raise a concern every round; the mitigation is the `severity` field plus a minimal rate limit on the driver side (same-concern dedup within 30 seconds). The cost of that abuse is that the agent stalls itself and cannot make progress, so the incentive is weak. Once a goal has been revised, the original goal is lost; each revise persists a `loop/goal-revised` session event with rationale, and later replay can select any historical goal version without claiming workspace restoration. - -### User surface - -Four trigger surfaces share one driver: - -- **Agent-side tool**: `loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` registers `kind: 'loop'` through `ctx.tasks`, returns the task id immediately, and runs the harness loop in the background. `task_output`, `task_list`, and `task_kill` provide collection and cancellation. Inside a running loop, the internal agent can call `loop_flag_concern({ concern, severity })` to raise a concern proactively. ACP rendering intent is `generic`. An agent-initiated call is proactive triggering with no extra machinery -- **CLI**: `dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh` is human-initiated startup, the most typical Ralph-style usage -- **cordis leaf**: declare a resident loop as a leaf in `cordis.yml`, with future `dsh-schedule` RFC integration for periodic triggering -- **ACP slash command**: `/loop ` (and `/loop-flag-concern`) starts directly from within the editor or client's current session. Semantically equivalent to a human typing `dsh-sdk loop` in a shell, but happens within the ongoing ACP session context, letting the loop result inject back into the session - -The ACP slash command depends on: `packages/ui/acp`'s `available_commands_update` surface is currently unbuilt ([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md)). Once the harness's slash-command infrastructure lands, `/loop` and `/loop-flag-concern` only need to be registered against that infrastructure; the driver and tool interfaces do not change. This RFC reserves the names and specifies the argument shape, but does not commit the infrastructure itself—that belongs to a separate ACP catch-up RFC. - -The default system prompt carries two behavioral instructions, distributed with every built-in `loop` tool: - -1. No writing of `TODO`, `FAKE`, or `PLACEHOLDER` placeholders to superficially pass the evaluator -2. No writing of empty `try/except` or `catch(_)` blocks so the evaluator ignores errors - -Neither can be enforced at the seam layer; both are prompt-layer guidance and must not be described as hard constraints. Users may customize the system prompt; evaluators that require these rules must check them explicitly. - -### Relationship with existing code - -Direct reuse without modification: - -- `packages/subagent`'s `spawn` provider, `toolFilter`, and `persona`—the loop spawns a subagent per round; an LLM evaluator gets a scoped model-facing tool set, not a process-isolation guarantee -- `packages/tasks`—the model-facing loop is a `loop` task producer and reuses owner isolation, `task_output`/`task_list`/`task_kill`, completion notices, cancellation, and awaited cleanup -- The SQLite backend from `packages/session-persistence`—the loop-session persists -- `packages/session-query`—exact live and persisted session reads for post-hoc diagnosis -- `packages/compact`—the implementation basis for `handoff-continue-with-compaction` -- `packages/todo`—an optional progress representation in single-session continue mode -- If [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) lands first, the loop tool can use it for per-round UI updates - -Not touched: `packages/core/agent-loop` (the inner-loop semantics stay the same); `packages/workflow` (DAG orchestration vs. iterating one goal is an orthogonal relationship; the two READMEs cross-link in their "Related" section to describe the boundary). - -One dependency is not yet landed: - -- The ACP slash-command infrastructure (the `available_commands_update` surface)—see User surface. Before the infrastructure lands, the slash-command trigger is absent while the other three trigger surfaces work as normal - -The proposed [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) is an optional Phase 2 discovery improvement over the existing exact-read query service, not a dependency for Phase 1 event access. - -Continuation work can be deferred to Phase 2: the `SubagentRun.sendMessage` and `resume` methods exist as optional seam capabilities, but the current `subagent-spawn` provider deliberately exposes neither. The two `handoff-continue-*` backends therefore require provider implementations, capability checks, ownership tests, and a consumer surface—not only a new argument on `packages/subagent-tool`. Phase 1 ships only `handoff-fresh-with-summary` and does not touch subagent continuation. - -### Phasing - -**Phase 1** (the scope this RFC commits): the three-package seam; `StopCondition`; the orthogonal criteria/executor/isolation `EvaluatorSpec`, with built-in implementations for shell and LLM-judge execution and rubric/contract criteria shapes open for integration; Default-FAIL enforcement; evaluator and cumulative-budget backends; `handoff-fresh-with-summary`; `ctx.tasks` integration; the `loop_flag_concern` tool; the no-progress heuristic; the `onGoalConcern: 'stop' | 'notify-continue'` pair; the CLI; the tool; and the default system-prompt guidance. **Not included**: the SQLite FTS5 search surface, the ACP slash-command trigger surface (depends on the `available_commands_update` infrastructure), subagent continuation provider/tool work, the `GoalReflector` service, the stuck detector, the Reflector subagent, the `loop_split` tool, and built-in executors for every rubric/contract combination. - -**Phase 2**: the SQLite FTS5 search surface; the stuck detector (reproducing OpenHands's five patterns); subagent continuation provider implementations, capability checks, and consumer surface (unlocking the two continue handoffs); the `GoalReflector` service and Reflector subagent; the `onGoalConcern: 'reflect'` tier; the `loop_split` model-facing tool; and built-in executors for additional rubric/contract combinations. - -**Phase 3**: agent fleet (N parallel loops for the same goal, best result wins); integration with `dsh-schedule`; two-container evaluator isolation (evaluator definition files entirely inaccessible to the main agent, defending against reward hacking). - -## Alternatives considered - -**Extend `packages/core/agent-loop`**: add an "iterate on end_turn until goal" switch to the inner loop. Rejected—AGENTS.md says "new behavior goes on documented extension seams; changing agent-loop requires updating docs/architecture.md". The harness loop needs state across sessions and across agents; stuffing it into the inner loop tangles session semantics into two mixed layers. - -**Ship a single slash command `/loop` (Claude Code clone)**: minimal implementation. Rejected—the slash-command layer does not resolve the harness/inner boundary; the four design points (queryable session, tiered evaluator, pluggable handoff, pluggable goal reflector) have nowhere to sit at the slash-command layer, and every capability this RFC commits is lost. - -**Fully outsource to `packages/workflow`**: express the loop as a workflow node with a back edge. Rejected—workflow lacks first-class semantics for iteration, StopCondition, and Evaluator; forcing it means the evaluator has to masquerade as a phase, violating the architectural-isolation requirement that the evaluator be independent of the producer; the budget guardrail in workflow is phase-level rather than round-level, and the granularities do not match. - -**Hardcode a binary choice between A (fresh) and B (continue)**: the Ralph school and the LoopTroop school each have strong scenarios. Rejected—Pluggable RoundHandoff proposes a seam plus three built-in backends that cover both schools and allow hybrids. - -**Skip the evaluator seam, ship a few built-ins**: lighter. Rejected—the core value of Pluggable Evaluator and Budget is that team-private evaluators can extend the system. Hardcoding leaves long unattended users no option but to modify the main library. - -**Accept a free function that lacks an explicit `EvaluatorSpec`**: allow users to pass any `(result) => boolean`. Rejected—the criteria/executor/isolation dimensions force users to declare at start time what is judged, what runs the judgment, and what boundary protects it, preventing quiet regression to a weaker setup. A free function looks flexible but lets evaluator strength quietly regress, and the cost is heavy in long-run scenarios. - -**Introduce an independent memory engine (Beads / dex-style)**: an established approach to external state. Rejected—`packages/session-persistence` plus the existing exact-read `ctx.sessionQuery` already cover Phase 1 diagnosis, while SQLite FTS5 can add search later; the payoff of a new engine is far smaller than the maintenance cost. - -**Fold goal reflection into the Evaluator seam** (have the evaluator return "criteria are impossible"): rejected—it conflates "was the goal achieved" with "is the goal still correct", which are orthogonal concerns. `Evaluator` should stay independent, read-only, and simple. - -**Only ever add an event for goal-concern, no seam**: lighter. Phase 1 does use the event plus `stop`/`notify` policies; rejected as the final design because the Phase 2 `reflect` path needs a replaceable response strategy. The seam lands with that first caller rather than ahead of it. - -**Ship the full Reflector subagent in Phase 1**: more complete. Rejected—`loop_flag_concern` tool plus no-progress heuristic plus the two-policy `onGoalConcern` already covers 80% of scenarios; running an independent subagent every round is expensive, and introducing it on demand in Phase 2 is more sensible. - -**Do not ship `loop_split`; let users split themselves**: Phase 1 already does. Phase 2 adds it because long-run scenarios reveal that agents receiving an oversized goal will run it directly rather than split it, so explicit tool guidance is needed. - -## Acceptance criteria - -- The three packages `packages/loop/{loop,loop-driver,loop-tool}` are built as a capability seam; `dsh-loop` exports only types and registry -- `StopCondition` discrimination covers all branches (unit); `assertNever` closes the switch at compile time -- The Phase 1 services `Evaluator`, `BudgetPolicy`, and `RoundHandoff` can each be replaced by an external plugin (fixture: inject a mock implementation, driver calls it correctly); no `GoalReflector` service is registered before the Phase 2 `reflect` consumer exists -- `EvaluatorSpec`'s criteria/executor/isolation dimensions converge at compile time; the driver refuses to start a loop without a paired evaluator (fixture: `loop({ goal, evaluator: undefined })` returns a configuration error immediately) -- Default-FAIL fixture: when the evaluator report returns `{criterion, pass: true, evidence: []}`, the driver refuses that criterion flip and records an `evaluator/invalid-report` session event -- `RoundHandoff` receives the previous result, evaluator report, token usage, summary, session id, optional run handle, and cancellation signal; Phase 1's `fresh-with-summary` has unit coverage plus one pass-path e2e, while continuation backend tests wait for Phase 2 provider support -- `dsh-sdk loop` CLI e2e: given a goal plus a 3-round cap plus one shell evaluator, both the pass and exhaustion paths return a structured stop cause with a semantic exit code -- Evaluator scoping fixture: the main agent has fs.write while an LLM evaluator's model-facing tool set does not; the result and documentation still label `same-workspace` as non-isolated, and no `protectedPaths` guarantee is exposed -- Budget fixtures cover `perRoundUsd` admission plus cumulative `maxRounds`, `maxTokens`, and `maxUsd` across worker and evaluator usage; an in-flight overrun emits `budget-cap` with `observed` and `maximum` -- Goal concern fixture: `loop_flag_concern` is callable from the main agent and yields an ordinary `loop/goal-concern` session event; under `onGoalConcern: 'stop'` an `approval-required` StopCondition is emitted; under `'notify-continue'` the loop continues without nonexistent ACP priority metadata; the no-progress heuristic fires once when budget exceeds 50% with zero passes (with rate-limit dedup) -- The default system-prompt guidance (no TODO/FAKE/PLACEHOLDER, no empty catch) is distributed with the built-in `loop` tool, and a snapshot covers the prompt content without treating it as enforcement -- Each round's prompt, inner-loop result, evaluator report, and stop decision appear as session events and are readable through the existing exact-read `ctx.sessionQuery`; FTS5 search remains Phase 2 -- Model-facing loop startup returns a `loop` task id immediately; `task_output`, `task_list`, `task_kill`, parent-agent disposal, cancellation, producer reload, and service disposal cover owner isolation and awaited quiescence -- The "Related" sections in `packages/loop/README.md` and `packages/workflow/README.md` cross-link and describe the "when to use workflow vs. when to use loop" boundary clearly -- Unit 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene all green; the ACP rendering intent (`generic`) of the new tool has a snapshot - -## Risks - -**Conversation replay is not workspace restore**. Exact session reads already exist, and FTS5 improves historical discovery rather than enabling correctness. Replaying a round prefix against the current workspace can diagnose or redirect a run, but reproducing the execution world at that round requires Git/worktree/checkpoint support and an explicit policy for external side effects. - -**The boundary between `packages/workflow` and loop is a recurring FAQ**. "Is multi-round a loop or a workflow?"—both READMEs must state clearly: workflow is "steps known, agent to run undecided, parallel or serial orchestration"; loop is "agent decided, round count undecided, evaluator decides when to stop". Unclear docs cause users to pick the wrong one. - -**Evaluator reverse-optimization (reward hacking)**. In a sufficiently long loop, the agent can identify the evaluator's pattern and optimize against it—for example, discovering that "as long as `assert True` appears in a test file, it PASSes" and bypassing real completion that way. Phase 1's `same-workspace` mode does not prevent the agent from modifying tests or evaluator configuration through bash, code runtimes, or another write channel; the current `packages/fs` policy is not a path-isolation boundary. Users needing adversarial strength must choose an isolated worktree, read-only mount, container, or remote evaluator. Phase 3's two-container approach keeps the evaluator runtime (binary, rubric, dependency libraries) entirely inaccessible to the main agent, matching what Anthropic patch.py does. - -**Placeholder faking and over-defensive code**. Agents sometimes write `# TODO: implement` to sneak through a test, or write large amounts of `try/except: pass` to make the evaluator superficially PASS. These do not belong to the evaluator layer; they are prompt and training issues at the agent-generation stage. The two default system-prompt instructions in User surface are guidance only; users who add "static-check-forbid TODO and empty catch" rules to a custom evaluator get enforceable coverage. This class of problem cannot be cured at the seam layer. - -**Budget estimation drift and in-flight overrun**. Pricing can change, and cumulative token/USD usage becomes exact only after each worker, evaluator, compaction, or reflector request reports usage. Preflight protects a single round; cumulative caps stop the next request and may exceed the configured maximum by one in-flight request. The README reports both observed and maximum values and states that provider billing remains authoritative. - -**Background tasks are process-local**. `ctx.tasks` gives the model-facing loop owner isolation, generic collection/cancellation, completion notices, and awaited cleanup. Parent-agent or service disposal cancels and awaits the loop; a process crash cannot run cleanup, and durable restart remains outside Phase 1. - -**Long-run loop log growth**. A 100-round loop reaches MB scale for one session. `logDetail: 'summary'` is a safety net but Phase 1 defaults to `full`; Phase 2 adds summary semantics. - -**Pre-release allows direct evolution**. `SESSION_FORMAT_VERSION=0`; the `LoopRoundEvent` schema can change at any time. Backends reject old formats rather than maintain compatibility, matching the pre-release stance at the top of AGENTS.md. diff --git a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md deleted file mode 100644 index 6460254b07..0000000000 --- a/.agents/notes/proposed/feature/2026-07-16-harness-level-loop.zh.md +++ /dev/null @@ -1,343 +0,0 @@ -# Agent Note: harness 层 goal-based loop - -Status: proposed - -[English](2026-07-16-harness-level-loop.md) | 中文 - -## 问题 - -`packages/core/agent-loop` 只跑 inner loop:一次 turn 内推理加工具循环,模型返回 `end_turn` 就结束。其 README 明确写「No built-in turn budget」——预算是它自己承认的 gap。跨轮次调度落在 harness 层:跑到测试全绿、按 rubric 反复改稿、把 PRD 拆成 bead 逐个推进、无人值守跑一整晚。这几类任务今天都没有一等公民的实现。 - -现有代码里有三种「能凑合跑」的替代,都不够用: - -| 替代 | 问题 | -|---|---| -| `packages/workflow` 脚本表达 `while (!done)` | README 明写「No token-budget vocabulary」和「No journaling or resume」;父 turn 阻塞到脚本 settle。能跑几分钟的编排,跑不了几小时的长期任务 | -| 外部 shell `while :; do dsh-sdk …; done` | Ralph 风格的调度今天就能这么写。缺共享的 stop condition、budget、evaluator 词汇,每个使用者各自重发明;循环本身没有持久化对象可供事后诊断或恢复 | -| `packages/subagent` seam 的 `sendMessage`/`resume` | README 明写「Runtime steering and continuation are seam-only capabilities」。没有 model-facing consumer,模型只能起 fresh 子会话 | - -典型使用场景有三类。**自动化修复**:面前一个失败的测试套件,希望一个进程持续修改代码、跑测试、根据失败信息再修改,直到全绿或触达预算上限。**按 rubric 迭代改稿**:一份文档、代码或翻译需要满足打分标准,循环反复调整、独立评估者打分、直到达标或耗尽轮数。**无人值守长跑**:例如通宵把一个仓库从一种技术栈移植到另一种,下班前启动第二天回来看结果,全程只有预算兜底。三类共同的形态:几分钟到几小时、evaluator 决定成败、预算是硬约束、跑完还需要能回看和恢复。 - -## 提案 - -**Loop 有四种触发形态**,按谁在什么时候启动一轮划分: - -| 形态 | 谁触发 | 何时触发 | 现有对标 | 本 RFC | -|---|---|---|---|---| -| **turn-based** | 用户在会话里发一条消息 | 每一轮用户回复 | `packages/core/agent-loop` 现有一次 turn 内的推理与工具循环 | 不覆盖,已有实现 | -| **goal-based** | 用户或 agent 明确指定「跑到某条件为止」 | 一次启动,evaluator 判停 | Claude Code 的 `/goal`、Codex 的 `/goal`、Ralph 家族 | **本 RFC 覆盖** | -| **time-based** | scheduler | 按 cron 或时间间隔 | Claude Code 的 `/loop`(周期性)、`/schedule` | 延后到 `dsh-schedule` RFC | -| **proactive** | agent 自己 | agent 在推理中意识到需要开一个 loop 时 | Anthropic ClaudeDevs 4 类分类里的 proactive 档 | **本 RFC 自然包含**(agent 调 `loop` tool 就是 proactive) | - -本 RFC 只**新增 capability seam `packages/loop/`** 处理 goal-based 一种。proactive 复用同一 `loop` tool,agent 主动调用即触发,无需额外机制。time-based 需要独立的 scheduler package,属于另一份 RFC 的事情;本 RFC 只在 cordis leaf 触发面预留跟未来 `dsh-schedule` 联动的钩子。 - -三个包: - -- `@deepseek-ai/dsh-loop`:类型、`LoopDriver` service、`StopCondition` 判别联合、Phase 1 service 定义(`Evaluator` / `BudgetPolicy` / `RoundHandoff`)、事件 schema;`GoalReflector` 在 Phase 2 与首个调用方一起加入 -- `@deepseek-ai/dsh-loop-driver`:默认 driver 实现 -- `@deepseek-ai/dsh-loop-tool`:model-facing `loop` tool + CLI `dsh-sdk loop` - -设计围绕四个具体问题展开,在每项能力出现调用方的 phase 中通过 service seam 或显式 driver policy 解决: - -1. 长跑 loop 出问题后缺诊断和恢复手段。**loop 作为独立 session** 解决。 -2. loop 结束时的 PASS 是否可信决定几小时工作是否作废。同一个 LLM 既生成又自评的架构本身就不可信。**Evaluator 与 Budget 做成 service seam** 解决。 -3. 短任务和长任务需要的记忆策略相反,硬编一种模式会让另一类场景不可用。**RoundHandoff 做成 service seam** 解决。 -4. 用户初始给的 goal 未必始终正确。agent 沿着错的目标蛮干会耗尽预算做错事。**Phase 1 用 goal concern event 与 policy 处理;GoalReflector service 随 Phase 2 的 `reflect` 路径一起加入**。 - -四条 seam 之外还有一条贯穿全文的原则:**一个 loop 只处理一个原子目标**。大目标拆成若干小 loop 串联,不塞进一个 loop 让 evaluator 判定多件事。判定 granularity 是否合适的经验规则:如果 loop 跑完说不清它到底做完了什么,granularity 就太大,应当拆。Phase 2 补 `loop_split` model-facing tool 让 agent 收到过大 goal 时能自己拆。 - -术语约定:**inner loop** 指 `packages/core/agent-loop` 一次 turn 的推理与工具循环;**harness loop** 指本 RFC 引入的外层调度器,围绕 inner loop 反复迭代。本 RFC 不改 `agent-loop`,符合 AGENTS.md「Plugins, not loop changes」。 - -`StopCondition` 是 discriminated union,`assertNever` 收口: - -```ts -interface EvaluatorReport { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - -type StopCondition = - | { kind: 'goal-met'; evidence: EvaluatorReport } - | { kind: 'budget-cap'; scope: 'usd' | 'tokens' | 'rounds'; observed: number; maximum: number } - | { kind: 'stuck'; pattern: 'repeat-action' | 'no-progress' | 'error-loop' } - | { kind: 'approval-required'; reason: string } - | { kind: 'user-cancel' } - -export {} -``` - -### Loop 作为独立 session - -长跑 loop 一旦出错,用户没有系统的诊断手段。跑几小时后失败,只能翻散落的日志文件。发现中间某一轮走偏想倒回去重跑,只能从头开始。agent 想参考自己过去 loop 的经验也没有可用的 API。 - -Driver 为每个 loop 开一个独立的 loop-session(新的 session id)。每轮的输入、inner-loop 结果、evaluator 报告、stop 决策都作为 session event 落盘,复用 `packages/session-persistence` 的 SQLite backend。得到三种诊断与 replay 能力。 - -- **从已记录轮次 replay 对话**:源 session 仍 live 时,发现第 78 轮偏航,可以 fork 第 77 轮的 event prefix,换 prompt 或 evaluator;已持久化 session 的 replay 还需要独立的受信任 load-and-seed 路径。两者都只会基于当前工作区 replay 对话状态,不会恢复第 77 轮的文件与外部副作用 -- **事后诊断**:通过现有 `ctx.sessionQuery` 精确读取 service 检查 evaluator 从哪一轮开始一直挂在同条 criterion 上 -- **元循环学习**:拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 后续可以在新 loop 启动前找到相关历史 loop——「我以前 fix 过类似的 bug 吗?失败在哪一轮?」 - -Claude Code、Codex 的 `/goal` 是一次性对象:跑完就丢,agent 下次遇到同类问题从零开始。 - -**存储与恢复边界**。每轮几 KB events,100 轮 loop 约 100–500 KB;跑几千个 loop 会到 GB 级。`logDetail: 'summary' | 'full'` 配置缓解,默认 `full`,长跑用户可切 `summary`。中间态全持久化会把生成过的 key、密码一并落盘,跟普通 session 是同一类风险但量放大 10–100 倍,README 明确提示。通过 `ctx.sessionQuery` 的精确 live 与已持久化读取已经存在;FTS5 是可选的发现能力增强,不是 Phase 1 依赖。本 RFC 不承诺精确恢复执行世界:`SessionStore.fork()` 只接受 live session,而 session event 不会恢复文件、进程、环境或外部副作用。这需要单独的 Git/worktree/checkpoint 设计。 - -### 可插拔的 Evaluator 与 Budget - -loop 的价值最终取决于结束时的 PASS 是否可信。如果 evaluator 会被 hack 或幻觉 PASS,前面几小时的工作全部作废。同一个 LLM 既生成又自评的架构本身就不可信:模型有条件说服自己 PASS。即便让独立 subagent 做 evaluator,只要 evaluator 还是 LLM,就仍然对同类内容有系统性偏好——独立 subagent 只是缓解不是根治。 - -可信评估同时需要确定性的判断机制,以及与 threat model 匹配的隔离边界:shell exit code、静态分析或外部服务避免 LLM 自评;独立 worktree、只读 mount、容器或远程服务防止 worker 改写 evaluator 输入。具体检查和边界只有用户知道:不同项目 `pytest` 命令不同、公司有私有合规检查器、有些团队还要跑内部 lint。主库无论内置几种都覆盖不全。所以 evaluator 必须做成用户可以自己接入的 seam。 - -预算方面同理:产品级的花费护栏是黑盒,无法按团队策略调整(个人卡、团队分摊、按 PR 结算)。 - -`Evaluator` 和 `BudgetPolicy` 都作为 cordis service seam 暴露。`Goal` 必须携带一个明确档位的 `EvaluatorSpec`,driver 拒绝启动没有 evaluator 配对的 loop——含糊的目标("把代码写好")不能进入 loop 系统: - -```ts -interface RubricItem { name: string; description: string } -interface EvaluatorContract { readonly name: string } - -type CriteriaSpec = - | { kind: 'single-metric'; name: string } - | { kind: 'rubric'; criteria: RubricItem[] } - | { kind: 'contract'; interface: EvaluatorContract } - -type ExecutorSpec = - | { kind: 'shell'; command: string } - | { kind: 'llm-judge'; rubric: string; model: string } - | { kind: 'provider'; name: string; config?: unknown } - -type IsolationSpec = 'same-workspace' | 'separate-worktree' | 'container' | 'remote' - -interface EvaluatorSpec { - criteria: CriteriaSpec - executor: ExecutorSpec - isolation: IsolationSpec -} - -export {} -``` - -**为什么使用显式维度,而不是让用户传自由函数?** spec 强制用户在启动时声明评估什么、由什么执行判断,以及什么隔离边界保护它。自由函数看起来灵活,实际让 evaluator 强度隐性下沉——用户以为在做确定性隔离检查,实际写的是同工作区 LLM 判断。长跑场景下代价是几小时白跑。 - -criteria shape、executor 与 isolation 是三个正交维度,不是可信度阶梯:rubric 可以由 shell、LLM 或外部服务检查,contract 也可以在同一工作区或容器中运行。`llm-judge` 仍是最弱的 executor,仅用于确实无硬信号的软目标(改稿、翻译质量)。文档明确标注「此 executor 不能挡定向对抗,长跑无人值守场景至少需要一个确定性 evaluator,并配合与 threat model 匹配的隔离边界」。 - -Driver 强制四条结构约束,不下放给 Evaluator 实现。隔离强度仍是已配置提供方的显式属性,不是 driver 能凭空制造的保证。 - -**防「同一个 agent 既生成又自评」**: - -1. **LLM 评估使用 fresh subagent**:LLM evaluator 每轮开 fresh subagent(用 `dsh-subagent` 的 `spawn`),不继承主循环 context - -**防 evaluator subagent 自身被 subverted**: - -2. **限制模型可见工具集**:LLM evaluator 的 model-facing tool set 被 driver 剥离到只保留读类工具,写类工具禁用(复用 `SubagentProvider` 的 `toolFilter`)。这会减少意外修改,但不是进程隔离:除非已配置隔离边界拦截,否则 shell、代码运行时或其他 capability 仍能写入 - -**防 evaluator 报告本身欺骗 driver**: - -3. **PASS 只能由 evaluator 报告翻转**:`goal-met` StopCondition 只能来自 evaluator,driver 或主 agent 都不能直接构造 -4. **Default-FAIL**:driver 内部维护每个 criterion 的 pass 状态默认 `false`,只有 evaluator 报告里带非空 evidence 才允许翻 `true`;evaluator 无法通过返回 `{pass: true}` 而不给 evidence 让 driver 接受 - -四条一起保证 evaluator 结论在结构上由证据推动,而不是由自信推动。它们不能阻止主 agent 在共享工作区中修改 evaluator 输入。 - -**Phase 1 内置三个 backend**: - -- `loop-evaluator-shell` 实现 `single-metric`:跑 shell 命令,`exit 0` = pass -- `loop-evaluator-rubric-judge` 实现 `llm-judge`:预写 rubric + LLM 打分,仅软目标 -- `loop-budget-preflight`:每轮启动前估 `(promptTokens + overhead + estOutputTokens) / 1M × pricePerMTok`,超 `perRoundUsd` 拒绝启动。估算模型来自 MartinLoop `policy.ts:551-596` - -`PricingProvider` 服务注入 pricing 表,test seam 可覆盖,不硬编到 driver 里(AGENTS.md「No hardcoded tunables in plugins」)。解析后的 budget 携带 `maxRounds`、可选 `maxTokens` 与 `maxUsd`,以及可选 `perRoundUsd`。driver 在启动工作前检查单轮准入,随后在每次请求后累计 worker、evaluator、compaction 和 reflector 用量。token 或 USD 上限可能被一个在途请求超出,因为 usage 在完成后才到达;`budget-cap` 结果同时报告 `observed` 与 `maximum`。`rubric` 与 `contract` criteria shape 在 Phase 2 补内置 executor,Phase 1 暴露这些 shape 让第三方插件先接。 - -**局限**:`same-workspace` 加只读 model-facing tool set 不是硬隔离。当前 `packages/fs` policy 实施 read-before-edit 与版本保护,不是路径拒写;bash 或代码运行时可以绕过 filesystem tool。挡定向对抗需要覆盖所有写入通道的边界,例如只读 mount、隔离 worktree、容器或远程 evaluator。两容器方案(evaluator 定义文件对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路)仍在 Phase 3。见 风险。 - -### 可插拔的 RoundHandoff - -每轮之间如何传递 context 是一个两难。完整保留之前对话(continue)连续性好,但对话会持续增长最终撞上 context 上限,且上一轮的错误信息会污染后续每一轮。每轮从零开始(fresh)避免污染,但每次需要重新理解上下文。跑 3 轮改稿与跑 80 轮 overnight 修 bug 需要的策略是相反的。Claude Code、Codex 都硬编一种模式,用户没法按任务类型切换。 - -做成 service seam: - -```ts -interface ContinuationRun { - readonly id: string - resume?(prompt: string): Promise -} - -interface PreviousRound { - result: unknown - evaluator: { criteria: readonly { name: string; pass: boolean; evidence: readonly string[] }[] } - tokenUsage: number - summary: string - sessionId: string - run?: ContinuationRun -} - -interface RoundContext { loopId: string; round: number; previous: PreviousRound } - -type NextRoundSpec = - | { mode: 'fresh'; prompt: string } - | { mode: 'continue'; run: ContinuationRun; prompt: string } - -interface RoundHandoff { - buildNextRound(prev: RoundContext, signal: AbortSignal): Promise -} - -export {} -``` - -Phase 1 交付 fresh backend;Phase 2 在 provider continuation 存在后增加两个 continuation backend: - -| Backend | Phase | 场景 | 机制 | -|---|---|---|---| -| `handoff-fresh-with-summary`(默认) | Phase 1 | 长跑、无人值守 | 每轮开 fresh subagent,只注入一段 progress 摘要作 system prompt 附加段 | -| `handoff-continue-with-compaction`(推荐中间档) | Phase 2 | 5–20 轮的中等长度 | 整段对话保留到 token 阈值,超了复用 [`packages/compact`](../../../../packages/compact/README.md) 压缩,摘要 + 最近 K 轮作起点 | -| `handoff-continue-raw`(专业档) | Phase 2 | ≤5 轮短任务、测试 | 纯连续对话不裁剪 | - -**为什么默认 fresh?** 所有实际跑成的长跑 loop(repomirror、Kimi ralph-loop、autoresearch)用的都是 fresh。把重要 loop 状态放在 context window 外由 driver 管理是长跑的正确姿势。`handoff-continue-raw` 违反这条经验,README 明写长跑不适用。 - -**为什么中间档只有我们能做?** `handoff-continue-with-compaction` 依赖 compaction seam——竞品都没有,只有本仓库 `packages/compact` 提供了这个基础设施。 - -**为什么做成 seam 而不是三选一 flag?** 用户可以写 20 行插件表达「前 5 轮 continue、之后 fresh」这类混合策略,或表达「context 到 50% 自动 compact 一次」,不用等主库支持。 - -**局限**:`continue-with-compaction` 依赖 `packages/compact` 的压缩质量,压缩本身可能把幻觉信息写进摘要传下去;README 建议长跑首选 fresh。三个 backend 的边界会让新用户不知道选哪个;`dsh-sdk loop` CLI 默认用 fresh,用户在遇到具体问题前不需要理解这些差别。 - -### 可插拔的 GoalReflector - -用户在启动 loop 时给的目标不一定准确。可能基于错误假设(让 agent 用某个已经废弃的 API 实现功能),可能不够清晰(agent 在做的过程中才发现需要澄清),也可能被后来的信息证伪。现在的循环执行框架把 goal 当作启动时冻结的合约,agent 只能沿着原路蛮干,结果是在错的方向上耗尽预算。 - -Phase 2 把它做成 service seam,与 `Evaluator` 职责分离:evaluator 问「是否达成目标」,reflector 问「目标是否还是那个目标」。Phase 1 只携带 concern event,以及 `stop` 与 `notify-continue` driver policy,不注册没有调用方的 `GoalReflector` service。 - -```ts -interface RoundContext { loopId: string; round: number } -interface GoalConcern { concern: string; severity: 'low' | 'medium' | 'high' } - -interface GoalReflector { - reflect(ctx: RoundContext, concerns: GoalConcern[]): Promise -} - -type GoalReflection = - | { kind: 'continue' } // goal 仍有效 - | { kind: 'revise'; newGoal: string; why: string } // 建议修正 goal - | { kind: 'stop-for-human'; reason: string } // 需要人拍板 - -export {} -``` - -**concern 有三种触发来源**。Phase 1 实现前两种;`GoalReflector` service 与周期性来源一起在 Phase 2 加入: - -- **agent 主动**:通过 model-facing tool `loop_flag_concern({ concern, severity })`。agent 在调研中意识到「用户假设的那个库已经废弃」时可以直接 raise -- **driver 启发式**:预算过 50% 且零 criterion pass 时,driver 自动 raise `no-progress-toward-goal` concern -- **周期性 reflector subagent**(Phase 2):每 N 轮独立跑一个只读 subagent 复审 goal 有效性,与 evaluator 独立性遵循同一思路 - -**响应策略通过 `onGoalConcern` 配置项**。这四种配置对应不同的 loop 使用哲学,用户按团队协作方式选,driver 不预设立场: - -- `'stop'`(Phase 1 默认):任何 concern 都触发 `StopCondition: approval-required`,人拍板。loop 在遇到任何不确定性时都不应自己往下走,适合谨慎风格团队与影响面较大的 loop 场景 -- `'notify-continue'`(Phase 1):记录普通 `loop/goal-concern` session event 后继续跑,人在结束时集中审阅。ACP 没有通用高优先级 marker,因此专用 concern 渲染与 ACP command 基础设施一起后置。loop 内部不打扰,适合无人值守长跑 -- `'reflect'`(Phase 2):调 `GoalReflector` 决定 continue、revise 还是 stop。委派一个独立 agent 代替人做初步判断,适合中等自主度的团队 -- 不注册 `GoalReflector` 且 `onGoalConcern` 未设 = 最放手档,loop 只在传统 stop condition 触发时停 - -**为什么默认选 `stop`?** 无人值守场景下宁可多停一次也不要在错方向上跑几小时。用户明确要无人值守可切 `notify-continue`。 - -concern 本身就是普通 session event,跟前文的持久化 session 能力天然协同:后续 replay 可以从 concern 出现的轮次为新对话提供 seed,并替换 goal。这不会把工作区回滚到该轮。 - -**滥用与丢失防护**。agent 可能每轮都 raise concern;缓解是 `severity` 字段和 driver 侧的最小 rate limit(同一 concern 30 秒内去重)。这种滥用的代价是 agent 卡住自己无法推进,动机不强。goal 被 revise 后原始 goal 会丢失;每次 revise 落 `loop/goal-revised` session event 带 rationale,后续 replay 可选任意历史 goal 版本,但不承诺恢复工作区。 - -### 用户面 - -四个触发面共享同一个 driver: - -- **agent 侧 tool**:`loop({ goal, evaluator, maxRounds, maxUsd, onGoalConcern })` 通过 `ctx.tasks` 注册 `kind: 'loop'`,立即返回 task id,并在后台运行 harness loop。`task_output`、`task_list` 和 `task_kill` 负责收集与取消。正在跑的 loop 内部 agent 可用 `loop_flag_concern({ concern, severity })` 主动发起 concern。ACP 渲染意图为 `generic`。agent 自主发起就是 proactive 触发,无需额外机制 -- **CLI**:`dsh-sdk loop --stop --max-rounds N --max-usd X --handoff fresh`。人类主导启动,最典型的 Ralph 风格用法 -- **cordis leaf**:`cordis.yml` 里以 leaf 形式声明常驻循环,配合未来的 `dsh-schedule` RFC 可做周期性触发 -- **ACP slash command**:`/loop `(还有 `/loop-flag-concern`)在编辑器/客户端的当前会话里直接启动。语义等价于人类在 CLI 里敲 `dsh-sdk loop`,但发生在正在进行的 ACP session 上下文中,允许 loop 结果直接注入会话 - -ACP slash command 的依赖:`packages/ui/acp` 的 `available_commands_update` 面目前是 unbuilt 状态([acp-feature-support.md](../../../../packages/ui/acp/acp-feature-support.md))。等 harness 的 slash command 基础设施落地,`/loop` 与 `/loop-flag-concern` 只需在该基础设施里注册;driver 与 tool 接口不变。本 RFC 保留名字并给出参数 shape,但不承诺基础设施本身——那属于独立的 ACP 补齐 RFC。 - -默认 system prompt 里有两条行为指令,随所有内置 `loop` tool 一起分发: - -1. 不允许写 `TODO`、`FAKE`、`PLACEHOLDER` 占位符让 evaluator 表面通过 -2. 不允许写空的 `try/except` 或 `catch(_)` 让 evaluator 忽略错误 - -这两条无法在 seam 层强制,只是 prompt 层 guidance,不能描述成硬约束。用户可以自定义 system prompt;需要强制这些规则的 evaluator 必须显式检查。 - -### 与仓库现有代码的关系 - -直接复用无需修改: - -- `packages/subagent` 的 `spawn` provider、`toolFilter`、`persona`——loop 每轮起 subagent;LLM evaluator 获得受限的 model-facing tool set,不获得进程隔离保证 -- `packages/tasks`——model-facing loop 是 `loop` task producer,复用 owner isolation、`task_output`/`task_list`/`task_kill`、完成通知、取消和 awaited cleanup -- `packages/session-persistence` 的 SQLite backend——loop-session 落盘 -- `packages/session-query`——精确读取 live 与已持久化 session,用于事后诊断 -- `packages/compact`——`handoff-continue-with-compaction` 的实现基础 -- `packages/todo`——单会话 continue 模式下作为可选 progress 表达 -- 若 [ToolExecution.reportProgress](2026-07-13-stream-workflow-progress-through-tool-calls.md) 先落地,loop tool 可用它逐轮 UI 更新 - -不动:`packages/core/agent-loop`(inner loop 语义保持);`packages/workflow`(DAG 编排 vs. 迭代同 goal 是 orthogonal 关系,两个 README 在「Related」段互链说明边界)。 - -依赖尚未落地的一处: - -- ACP slash command 基础设施(`available_commands_update` 面)——见 用户面。基础设施落地前,slash command 触发面缺席,其它三个触发面照常工作 - -拟议中的 [SQLite FTS5 search](2026-07-10-sqlite-session-query-provider.md) 是现有 exact-read query service 之上的可选 Phase 2 发现能力增强,不是 Phase 1 event 访问的依赖。 - -Continuation 工作可以延后到 Phase 2:`SubagentRun.sendMessage` 与 `resume` 方法作为可选 seam capability 存在,但当前 `subagent-spawn` provider 明确不暴露这两个方法。因此,两个 `handoff-continue-*` backend 需要 provider 实现、capability check、ownership 测试和 consumer surface,不只是给 `packages/subagent-tool` 增加参数。Phase 1 只交付 `handoff-fresh-with-summary`,不改 subagent continuation。 - -### 分阶段 - -**Phase 1**(本 RFC 承诺范围):三包 seam;`StopCondition`;criteria/executor/isolation 三个正交维度的 `EvaluatorSpec`,其中 shell 与 LLM-judge execution 有内置实现,rubric/contract criteria shape 开放待接;Default-FAIL 强制;evaluator 与累计 budget backend;`handoff-fresh-with-summary`;`ctx.tasks` 集成;`loop_flag_concern` tool;no-progress 启发式;`onGoalConcern: 'stop' | 'notify-continue'` 二档;CLI;tool;默认 system prompt guidance。**不含**:SQLite FTS5 search 面、ACP slash command 触发面(依赖 `available_commands_update` 基础设施)、subagent continuation provider/tool 工作、`GoalReflector` service、stuck 检测器、Reflector subagent、`loop_split` tool,以及每种 rubric/contract 组合的内置 executor。 - -**Phase 2**:SQLite FTS5 search 面;stuck 检测器(复现 OpenHands 5 种模式);subagent continuation provider 实现、capability check 与 consumer surface(解锁两个 continue handoff);`GoalReflector` service 与 Reflector subagent;`onGoalConcern: 'reflect'` 档;`loop_split` model-facing tool;更多 rubric/contract 组合的内置 executor。 - -**Phase 3**:agent fleet(同 goal 派 N 个并行 loop 取最优);与 `dsh-schedule` 集成;两容器 evaluator 隔离(evaluator 定义文件对主 agent 完全不可访问,防 reward 反向优化)。 - -## 备选方案 - -**扩 `packages/core/agent-loop`**:给 inner loop 加「iterate on end_turn until goal」开关。拒绝——AGENTS.md「新行为走文档化扩展 seam;改 agent-loop 需要更新 docs/architecture.md」。harness loop 需要跨 session、跨 agent 的状态,塞进 inner loop 会把 session 语义拧成两层混合。 - -**只做一个 slash command `/loop`(Claude Code 复刻)**:实现最简。拒绝——slash-command 层不解决 harness/inner 边界;四条设计要点(可查询 session、分档 evaluator、可插拔 handoff、可插拔 goal reflector)在 slash-command 层没有承载点,本 RFC 承诺的能力全部丢失。 - -**全权外包给 `packages/workflow`**:把 loop 表达成带回边的 workflow 节点。拒绝——workflow 缺 iteration、StopCondition、Evaluator 的一等公民语义。硬用会把 evaluator 冒充成一个 phase,违反 evaluator 独立于 producer 的架构隔离要求;预算护栏在 workflow 是 phase-level 而非 round-level,粒度对不上。 - -**A(fresh)vs. B(continue)硬编二选一**:Ralph 派和 LoopTroop 派各自都有强场景。拒绝——可插拔的 RoundHandoff 提出 seam + 三档内置 backend 涵盖两派并允许 hybrid。 - -**不做 evaluator seam,内置几种够用**:更轻。拒绝——可插拔的 Evaluator 与 Budget 的核心价值是团队或私有 evaluator 可扩展。写死后长跑无人值守场景的用户只能改主库。 - -**接受不带显式 `EvaluatorSpec` 的自由函数**:允许用户传任意 `(result) => boolean`。拒绝——criteria/executor/isolation 维度强制用户在启动时声明评估什么、由什么执行判断、由什么边界保护,防止不知不觉滑到更弱的配置。自由函数看起来灵活,实际让 evaluator 强度隐性下沉,长跑场景代价大。 - -**引入独立记忆引擎(Beads / dex-style)**:外部化状态的成熟做法。拒绝——`packages/session-persistence` 加现有 exact-read `ctx.sessionQuery` 已经覆盖 Phase 1 诊断,SQLite FTS5 后续可以补 search;新引擎收益远小于维护成本。 - -**goal reflection 塞进 Evaluator seam**(让 evaluator 返回「criteria 不可能满足」):拒绝——混淆「是否成功」和「目标是否正确」两个正交问题。`Evaluator` 应保持独立、只读、简单。 - -**goal-concern 永远只做 event 不做 seam**:更轻。Phase 1 确实使用 event 加 `stop`/`notify` policy;作为最终设计仍拒绝,因为 Phase 2 的 `reflect` 路径需要可替换响应策略。seam 与首个调用方一起落地,不提前出现。 - -**Phase 1 就上完整 Reflector subagent**:更全。拒绝——`loop_flag_concern` tool + no-progress 启发式 + 二档 policy 覆盖 80% 场景;每轮跑独立 subagent 成本高,Phase 2 按需引入更合理。 - -**不做 `loop_split`,用户自己拆**:Phase 1 已经如此。Phase 2 加是因为长跑场景发现 agent 收到过大 goal 会直接跑而不是自己拆,需要显式工具引导。 - -## 验收标准 - -- `packages/loop/{loop,loop-driver,loop-tool}` 三包按 capability seam 建成;`dsh-loop` 只导 types 与 registry -- `StopCondition` 判别覆盖所有分支(单元),`assertNever` 编译期收口 -- Phase 1 的 `Evaluator`、`BudgetPolicy`、`RoundHandoff` 三条 service 都能被外部插件替换(fixture:注入 mock 实现,driver 正确调用);Phase 2 `reflect` consumer 出现前不注册 `GoalReflector` service -- `EvaluatorSpec` 的 criteria/executor/isolation 维度在编译期收敛;driver 拒绝启动没有 evaluator 配对的 loop(fixture:`loop({ goal, evaluator: undefined })` 立即返回配置错误) -- Default-FAIL fixture:evaluator 报告返回 `{criterion, pass: true, evidence: []}` 时 driver 拒绝该 criterion 翻转、记 `evaluator/invalid-report` session event -- `RoundHandoff` 接收上一轮 result、evaluator report、token usage、summary、session id、可选 run handle 和 cancellation signal;Phase 1 的 `fresh-with-summary` 有单元覆盖与一个 pass-path e2e,continuation backend 测试等待 Phase 2 provider 支持 -- `dsh-sdk loop` CLI e2e:给定 goal + 3 轮上限 + 一个 shell evaluator,通过与耗尽两条路径都返回结构化 stop cause 并 exit code 语义化 -- Evaluator scope fixture:主 agent 有 fs.write,LLM evaluator 的 model-facing tool set 没有;结果与文档仍把 `same-workspace` 标记为未隔离,不暴露 `protectedPaths` 保证 -- Budget fixture 覆盖 `perRoundUsd` 准入,以及跨 worker 与 evaluator usage 累计的 `maxRounds`、`maxTokens`、`maxUsd`;在途超限 emit 带 `observed` 与 `maximum` 的 `budget-cap` -- Goal concern fixture:`loop_flag_concern` 可从主 agent 调用并产出普通 `loop/goal-concern` session event;`onGoalConcern: 'stop'` 下 emit `approval-required` StopCondition;`'notify-continue'` 下继续跑且不携带不存在的 ACP priority metadata;no-progress 启发式在预算超 50% 且零 pass 时自动触发一次(rate-limit 去重) -- 默认 system prompt guidance(无 TODO/FAKE/PLACEHOLDER、无空 catch)随内置 `loop` tool 一起分发,snapshot 覆盖 prompt 内容但不把它当作强制机制 -- 每轮的 prompt、inner-loop 结果、evaluator report、stop 决策都以 session event 出现,并可通过现有 exact-read `ctx.sessionQuery` 读取;FTS5 search 留在 Phase 2 -- model-facing loop 启动后立即返回 `loop` task id;`task_output`、`task_list`、`task_kill`、父 agent dispose、取消、producer reload 和 service dispose 覆盖 owner isolation 与 awaited quiescence -- `packages/loop/README.md` 和 `packages/workflow/README.md` 的「Related」段互链清楚「何时用 workflow、何时用 loop」的边界 -- 单元 100% / snapshot / e2e / doc-sync / verify-module-graph / build / hygiene 全绿;新增 tool 的 ACP 渲染意图(`generic`)有 snapshot - -## 风险 - -**对话 replay 不是工作区恢复**。精确 session 读取已经存在,FTS5 改善历史发现能力,不决定正确性。基于当前工作区 replay 某一轮 prefix 可以诊断或改变运行方向,但复现该轮执行世界需要 Git/worktree/checkpoint 支持,以及针对外部副作用的显式 policy。 - -**`packages/workflow` 与 loop 的边界是持续答疑热点**。「多轮是 loop 还是 workflow」两个 README 必须写清楚:workflow 是「步骤已知、agent 未定、并串行编排」;loop 是「agent 已定、轮数未定、evaluator 判停」。文档不清晰会让用户混用错档。 - -**evaluator 反向优化(reward hacking)**。足够长的 loop 里 agent 有条件识别 evaluator 的模式并针对性优化,例如发现「只要测试文件里出现 `assert True` 就 PASS」从而绕过实质完成。Phase 1 的 `same-workspace` 模式不能阻止 agent 通过 bash、代码运行时或其他写入通道修改测试或 evaluator 配置;当前 `packages/fs` policy 不是路径隔离边界。需要对抗强度的用户必须选择隔离 worktree、只读 mount、容器或远程 evaluator。Phase 3 的两容器方案让 evaluator 整个运行时(二进制、rubric、依赖库)对主 agent 完全不可访问,Anthropic patch.py 走的就是这条路。 - -**占位符伪造与过度防御码**。agent 有时会写 `# TODO: implement` 让测试勉强通过,或写大量 `try/except: pass` 让 evaluator 表面 PASS。这些不属于 evaluator 层的问题,而是 agent 生成阶段的 prompt 与训练问题。用户面 段的两条默认 system prompt 指令只是 guidance;用户在自定义 evaluator 中加入「静态检查禁止 TODO 与空 catch」才能获得可强制覆盖。这类问题不是 seam 层能根治的。 - -**预算估算漂移与在途超限**。pricing 可能变化,累计 token/USD usage 只能在每次 worker、evaluator、compaction 或 reflector 请求报告 usage 后精确。preflight 保护单轮;累计上限会停止下一个请求,但可能被一个在途请求超出。README 同时报告 observed 与 maximum,并说明 provider 账单才是权威。 - -**后台 task 只存在于当前进程**。`ctx.tasks` 为 model-facing loop 提供 owner isolation、通用收集/取消、完成通知和 awaited cleanup。父 agent 或 service dispose 会取消并等待 loop;进程 crash 无法执行 cleanup,持久重启不在 Phase 1 范围内。 - -**长跑 loop 日志膨胀**。跑 100 轮 loop 单 session 上 MB 级。`logDetail: 'summary'` 兜底但 Phase 1 默认 `full`,Phase 2 再补 summary 语义。 - -**pre-release 允许直接演进**。`SESSION_FORMAT_VERSION=0`,`LoopRoundEvent` schema 可随时改;后端拒收旧格式而非兼容,与 AGENTS.md 顶部 pre-release stance 一致。 From e1c07cbabfba7bfe046edbbfda16748bd6597d3d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:38:14 +0800 Subject: [PATCH 049/132] fix(workflow): include Ralph in clean builds --- tsconfig.build.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.build.json b/tsconfig.build.json index c32520a618..05ece689aa 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -94,6 +94,7 @@ { "path": "./packages/workflow/workflow" }, { "path": "./packages/workflow/workflow-workerthread" }, { "path": "./packages/workflow/tool-workflow" }, + { "path": "./packages/workflow/tool-ralph" }, { "path": "./packages/todo/tool-todo" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/cordis/tool-cordis" }, From 1a14b605695a35472b12afe2b1ed8724f9e9e00a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:38:14 +0800 Subject: [PATCH 050/132] test(goal): close terminal pause e2e --- .../tests/fixtures/goal/tool-goal/scripted-llm.ts | 2 +- packages/goal/tool-goal/tests/tool-goal.e2e.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 39498f8e39..7b635ba0bb 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') + if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 037b91d18d..34f7bf243c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -16,6 +16,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const PAUSED_RESULT = '"phase":"paused"' let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -68,7 +69,8 @@ async function runComposition(): Promise<{ stdout: string; stderr: string }> { pauseSent = true proc.stdin.write('pause\n') } - if (!inputClosed && stdout.includes('GOAL PAUSED')) { + const pausedAt = stdout.indexOf(PAUSED_RESULT) + if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { inputClosed = true proc.stdin.end() } @@ -98,7 +100,8 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stderr).not.toContain('UNHANDLED') expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain('GOAL PAUSED') + expect(stdout).toContain(PAUSED_RESULT) + expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) From 3b0b0cefebb82901ed27ef64c0f4ff1d233ff54a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:34:19 +0800 Subject: [PATCH 051/132] feat: implement bounded LLM request recovery --- ...2026-06-21-bounded-llm-request-recovery.md | 41 +- .../2026-07-06-timeout-deadline-library.md | 25 +- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 6 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 6 +- docs/architecture.md | 8 +- docs/config-catalog.md | 52 +- docs/cordis-catalog/events.md | 17 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 24 +- docs/core-data-structures/llm-streaming.md | 24 +- docs/core-data-structures/session.md | 10 +- docs/event-producer-consumer.md | 10 +- docs/module-graph.md | 24 +- docs/persistence-catalog.md | 48 +- docs/testing.md | 2 + .../snapshots/error-finish/session.jsonl | 2 +- .../error-finish/stdout.expected.jsonl | 1 + packages/compact/compact-basic/package.json | 1 + packages/compact/compact-basic/src/index.ts | 7 +- .../compact/compact-basic/src/summarizer.ts | 10 +- .../compact-basic/tests/compact-basic.spec.ts | 12 +- .../tests/compact-loop-repro.spec.ts | 102 +++- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- packages/core/agent-loop/README.md | 3 +- packages/core/agent-loop/src/loop.ts | 52 +- .../tests/contract-regressions.spec.ts | 37 +- .../agent-loop/tests/coverage-edges.spec.ts | 7 +- .../agent-loop/tests/request-recovery.spec.ts | 91 +++- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 7 +- packages/core/session/README.md | 6 +- packages/core/session/src/types.ts | 10 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/src/index.ts | 3 + packages/examples/agent-spine-demo/README.md | 9 +- .../examples/agent-spine-demo/package.json | 4 +- .../examples/agent-spine-demo/src/index.ts | 11 +- .../agent-spine-demo/tests/agent-core.spec.ts | 45 +- .../examples/agent-spine-demo/tsconfig.json | 3 + packages/examples/cli-demo/README.md | 3 +- packages/examples/cli-demo/src/cli.ts | 12 +- packages/examples/cli-demo/src/index.ts | 3 + packages/examples/cli-demo/tests/cli.spec.ts | 26 + packages/examples/stdio-demo/README.md | 1 + packages/examples/stdio-demo/src/index.ts | 3 + packages/llm/README.md | 3 +- packages/llm/llm-deepseek/README.md | 7 +- packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/adapter.ts | 98 +++- packages/llm/llm-deepseek/src/index.ts | 7 +- packages/llm/llm-deepseek/src/translate.ts | 5 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 198 +++++++- .../llm/llm-deepseek/tests/translate.spec.ts | 3 +- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/README.md | 11 +- packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 61 ++- packages/llm/llm-pi-ai/src/config.ts | 37 +- packages/llm/llm-pi-ai/src/stream.ts | 18 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 159 +++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 50 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 2 +- .../llm/llm-pi-ai/tests/sdk-options.spec.ts | 35 ++ packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm-retry/README.md | 39 ++ packages/llm/llm-retry/package.json | 47 ++ packages/llm/llm-retry/src/index.ts | 211 ++++++++ .../tests/loader-composition.spec.ts | 124 +++++ .../llm/llm-retry/tests/persistence.spec.ts | 57 +++ packages/llm/llm-retry/tests/retry.spec.ts | 453 ++++++++++++++++++ packages/llm/llm-retry/tsconfig.json | 33 ++ packages/llm/llm/README.md | 11 +- packages/llm/llm/src/adapter-failure.ts | 83 +++- packages/llm/llm/src/brand.ts | 15 +- packages/llm/llm/src/error.ts | 16 + packages/llm/llm/src/index.ts | 48 +- packages/llm/llm/src/types.ts | 20 +- packages/llm/llm/tests/properties.spec.ts | 5 +- packages/llm/llm/tests/service.spec.ts | 168 +++++++ .../llm-replay/tests/llm-replay.spec.ts | 2 +- packages/ui/acp/README.md | 4 +- packages/ui/acp/package.json | 2 + packages/ui/acp/src/index.ts | 22 +- packages/ui/acp/tests/harness.ts | 2 +- packages/ui/acp/tests/stream-update.spec.ts | 27 ++ packages/ui/acp/tests/turns.spec.ts | 9 + packages/ui/acp/tsconfig.json | 3 + packages/ui/stdio/README.md | 2 +- packages/ui/stdio/package.json | 2 + packages/ui/stdio/src/index.ts | 26 +- packages/ui/stdio/tests/stdio.spec.ts | 45 ++ packages/ui/stdio/tsconfig.json | 3 + packages/ui/tui/README.md | 2 +- packages/ui/tui/package.json | 2 + packages/ui/tui/src/index.ts | 75 ++- packages/ui/tui/tests/harness.ts | 5 +- .../snapshots/retry-cancelled.expected.txt | 48 ++ .../snapshots/retry-exhausted.expected.txt | 45 ++ .../snapshots/retry-recovered.expected.txt | 49 ++ .../snapshots/retry-scheduled.expected.txt | 45 ++ packages/ui/tui/tests/tui.snapshot.ts | 81 ++++ packages/ui/tui/tests/tui.spec.ts | 75 ++- packages/ui/tui/tsconfig.json | 3 + packages/util/timeout/README.md | 7 +- packages/util/timeout/src/index.ts | 76 +++ packages/util/timeout/tests/timeout.spec.ts | 87 +++- pnpm-lock.yaml | 67 +++ python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 2 + tsconfig.build.json | 1 + tsconfig.json | 1 + website/zh-CN/api/harness/events.md | 18 +- website/zh-CN/api/harness/llm.md | 10 +- 115 files changed, 3311 insertions(+), 366 deletions(-) rename .agents/notes/{proposed => implemented}/architecture/2026-06-21-bounded-llm-request-recovery.md (82%) create mode 100644 packages/llm/llm-pi-ai/tests/sdk-options.spec.ts create mode 100644 packages/llm/llm-retry/README.md create mode 100644 packages/llm/llm-retry/package.json create mode 100644 packages/llm/llm-retry/src/index.ts create mode 100644 packages/llm/llm-retry/tests/loader-composition.spec.ts create mode 100644 packages/llm/llm-retry/tests/persistence.spec.ts create mode 100644 packages/llm/llm-retry/tests/retry.spec.ts create mode 100644 packages/llm/llm-retry/tsconfig.json create mode 100644 packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/retry-recovered.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt diff --git a/.agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md similarity index 82% rename from .agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md rename to .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index e57cffe687..ad476b8b35 100644 --- a/.agents/notes/proposed/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -1,6 +1,6 @@ # Agent Note: Bounded recovery for transient LLM request failures -Status: proposed +Status: implemented ## Problem @@ -8,19 +8,19 @@ Status: proposed That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. -Three narrower gaps remain. +The prior boundary left three narrower gaps. - Provider failures retain only a message and usually a code. HTTP status, retry delay, and provider request id are discarded or recoverable only through provider-specific error objects, so generic recovery cannot make or explain a decision without parsing text. -- Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with a future `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. +- Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. - A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. -## Proposal +## Decision ### Preserve failure facts without embedding policy -Add one JSON-serializable `LlmFailure` payload to `@deepseek-ai/dsh-llm`: +`@deepseek-ai/dsh-llm` exports one JSON-serializable `LlmFailure` payload: ```ts ignore-check type ProviderRequestId = Branded<'ProviderRequestId'> @@ -46,9 +46,9 @@ The initial shared transient-code set is intentionally small: the adapters' exis ### Put retry policy on the existing failed-step seam -Add a function plugin, `@deepseek-ai/dsh-llm-retry`, that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. +`@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. -Replace the scalar `retryAttempt` argument with the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history as it clears the current scalar. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. +The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. The plugin resolves and validates this deployment configuration at load: @@ -78,15 +78,15 @@ The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot C Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`. -`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk, but this proposal does not add one speculatively. +`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper. ### Bound stalled streams where they can be stopped -Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. -Extend `@deepseek-ai/dsh-timeout` with a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. +`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. -The two adapters must prove termination at their actual boundaries. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK only after a test proves the SDK stops the request. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. +Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. ### Keep attempts separate in the existing log @@ -112,31 +112,30 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. - **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. -## Acceptance criteria +## Verification - `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available. - An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. -- Pi-ai performs one wire attempt per adapter call, and a wire-level test rejects any regression that silently restores SDK retries. +- Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. - `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. - `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. - HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and has a production UI consumer with keyless ACP or TUI snapshot coverage for scheduled retry, cancellation during delay, and eventual success or exhaustion. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. -- The architecture LLM section, the implemented request-recovery and timeout notes, agent-loop and adapter READMEs, package catalogs, example configuration, persistence catalog, and testing documentation are updated in the implementation change; all generated outputs and bilingual counterparts required by those files are refreshed together. -## Risks +## Consequences -- A retry can duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. -- Provider SDKs may hide status or retry headers. Those adapters must use `UNKNOWN` or a stable coarse code rather than infer policy from fragile text. +- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. -- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots must make the transition explicit. -- Adapter-local timeout enforcement can drift across transport libraries. Contract tests at the termination boundary are required for both implementations. -- Multiple recovery plugins add their finite budgets. Their classifiers should remain disjoint; an overlapping classifier is registration-order policy and must be documented and tested by the plugins that introduce it. +- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. +- Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift. +- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 335581ecff..1c407d777d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -18,7 +18,7 @@ Each new external-process or network tool re-derived the same four things — cl ### The library surface -Three functions plus one reason type: +Four functions, one watchdog interface, and one reason type: ```ts ignore-check /** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */ @@ -51,19 +51,34 @@ export function deadline( code: string, ): { signal: AbortSignal; [Symbol.dispose](): void } +/** A stable signal plus one-at-a-time, timer-guarded async-iterator demand. */ +export interface IdleWatchdog { + readonly signal: AbortSignal + next(iterator: AsyncIterator): Promise> + [Symbol.dispose](): void +} + +/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */ +export function idleWatchdog( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): IdleWatchdog + /** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` fuses an upstream signal with a timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. +`deadline` fuses an upstream signal with a one-shot timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. `idleWatchdog` instead requires a positive finite interval, keeps one stable fused signal for the entire stream, and arms its timer only while one iterator `next()` is outstanding; resolution disarms it, later demand rearms it, concurrent demand fails, and disposal clears the active arm. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. ### The division of labor | Concern | Owner | |---|---| | Validate request hint and clamp default/max | `dsh-timeout` (`clampTimeout`) — pure arithmetic plus the shared positive-finite request contract | -| Arm timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) | -| Clear the timer | `dsh-timeout` (`[Symbol.dispose]`) | +| Arm one-shot timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) | +| Arm and rearm only around outstanding iterator demand | `dsh-timeout` (`idleWatchdog`) | +| Clear the timer | `dsh-timeout` (`[Symbol.dispose]` on either primitive) | | Classify the first abort reason after abort | `dsh-timeout` (`timeoutOf`) | | **Actually terminate the work** | the capability's implementation | | The default/max *values* | the capability's config | @@ -75,6 +90,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. - **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. +- **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`. ## Consequences @@ -82,6 +98,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. This is the one deviation from the literal proposal shape (which passed `timeoutMs: 0` into `runBash`); an always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). +- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate. Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; future ripgrep-backed fs discovery tools can consume the same provider-owned deadline shape once they exist; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3ed38c1282..3a6b18cecf 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-provider-routed-llm-adapters.md: b7944bd31fdb5f63894e867d7c1224215d694f11 -2026-07-14-provider-routed-llm-adapters.zh.md: 7dcadf2521bab079e328b5f0d0a45185778b3b8d +2026-07-14-provider-routed-llm-adapters.md: 98205d18d07752e0cdba86d7cba80368d45fd816 +2026-07-14-provider-routed-llm-adapters.zh.md: c35225a86baf4c2d09732b5940abbc8046d365fb diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index b7944bd31f..98205d18d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -28,7 +28,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` ### Explicit pi-ai provider profiles -`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, timeouts, and retry settings. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. +`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, and a Harness stream-idle timeout. Provider retry fields are deliberately absent: the adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` owns bounded agent-level recovery. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog. @@ -75,14 +75,14 @@ The on-disk session format remains the pre-release pinned version `0`, with no c - Provider names are deployment-wide route ownership keys: two providers may use the same model string, but mounting two adapters for one provider fails at load instead of creating fallback order. - Model selection no longer changes the Cordis plugin graph. Catalog-backed adapters can accept any installed catalog model selected after startup, while the native DeepSeek adapter forwards arbitrary DeepSeek model ids. - A custom `baseURL` preserves the selected catalog model's protocol and capabilities; it does not make catalog-external model ids valid. Private endpoints must implement that catalog entry's protocol. -- pi-ai credentials and transport knobs are scoped per provider profile. An omitted key delegates to pi-ai ambient authentication, while an explicitly empty key is invalid. +- pi-ai credentials, transport knobs, SDK timeouts, and the five-minute-default `streamIdleTimeoutMs` watchdog are scoped per provider profile. Hidden provider retries are disabled; bounded retries belong to the separately composed agent recovery policy. - `dsh-llm-pi-ai` rejects stop sequences because pi-ai's common stream API cannot express them; the native DeepSeek adapter retains its stop support. - Replay state is portable only within the adapter instance that owns both the historical and target providers. Cross-provider and cross-model restoration is an adapter responsibility, and another adapter receives provider-neutral history without the opaque state. - Current pre-release session JSONL requires provider/model request headers and assistant provenance. Older shapes remain version `0` but are rejected rather than migrated. ## Testing -- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, cancellation, content rewrites, and same-instance versus different-instance replay dispatch. +- Unit coverage exercises registry conflicts, request reconstruction, session validation, profile resolution, single-attempt option forwarding, native API selection including OpenAI Responses, conversion, replay validation, error mapping, caller cancellation, idle-timeout transport termination, content rewrites, and same-instance versus different-instance replay dispatch. - Keyless loop/session tests and ACP snapshots exercise durable provider/model metadata, resume and fork propagation, workflow/subagent overrides, and unchanged user-visible transcripts; the key-gated DeepSeek e2e retains real provider streaming and tool follow-up coverage. - Public JSDoc, package READMEs, architecture and core-data-structure docs, generated catalogs, examples, session fixtures, and Python SDK pairs use provider/model targets consistently and are checked by the repository documentation and type-equivalence gates. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 7dcadf2521..c35225a86b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -28,7 +28,7 @@ Status: implemented ### 显式 pi-ai 提供方配置 -`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、超时和重试设置。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 +`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时和 Harness 流空闲超时。配置中有意不提供重试字段:适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;有界的 agent 层恢复由 `dsh-llm-retry` 负责。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 @@ -75,14 +75,14 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方 - 提供方名称是部署范围内的路由所有权键:两个提供方可以使用相同的模型字符串,但为同一个提供方挂载两个适配器会在加载时失败,不会形成回退顺序。 - 模型选择不再改变 Cordis 插件图。目录型适配器可以接受启动后选择的任意已安装目录模型,原生 DeepSeek 适配器则会转发任意 DeepSeek 模型 ID。 - 自定义 `baseURL` 会保留所选目录模型的协议与能力,但不会让目录外模型 ID 变为有效。私有端点必须实现该目录项对应的协议。 -- pi-ai 凭据与传输选项按提供方配置隔离。省略密钥时委托 pi-ai 使用环境认证;显式空密钥无效。 +- pi-ai 凭据、传输选项、SDK 超时,以及默认五分钟的 `streamIdleTimeoutMs` 空闲超时机制均按提供方配置隔离。系统禁用隐藏的提供方重试;有界重试由单独组合的 agent 恢复策略负责。 - pi-ai 的通用流 API 无法表达停止序列,因此 `dsh-llm-pi-ai` 会拒绝停止序列;原生 DeepSeek 适配器仍支持停止序列。 - 仅当历史提供方与目标提供方归同一个适配器实例所有时,回放状态才可移植。适配器负责跨提供方和跨模型恢复;其他适配器只接收不含不透明状态的提供方无关历史。 - 当前预发布会话 JSONL 要求请求头包含 provider/model,助手消息包含来源信息。旧格式仍使用版本 `0`,但会被拒绝,不执行迁移。 ## 测试 -- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、取消、内容重写,以及同一实例与不同实例间的回放分发。 +- 单元测试覆盖注册表冲突、请求重建、会话验证、配置解析、单次请求的选项转发、包括 OpenAI Responses 在内的原生 API 选择、转换、回放验证、错误映射、调用方取消、空闲超时导致的传输终止、内容重写,以及同一实例与不同实例间的回放分发。 - 无密钥的 agent loop/会话测试和 ACP 快照覆盖持久化 provider/model 元数据、恢复与 fork 传播、工作流/subagent 覆盖,以及不变的用户可见 transcript(文本记录);密钥门控的 DeepSeek e2e 测试保留真实提供方的流式输出与工具后续调用覆盖率。 - 公共 JSDoc、package README、架构与核心数据结构文档、生成目录、示例、会话 fixture(测试前置数据)和 Python SDK 配对文档统一使用 provider/model 目标,并由仓库文档与类型等价门禁校验。 diff --git a/docs/architecture.md b/docs/architecture.md index 7cd9b34a2c..befa24c4c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,7 +85,7 @@ forever: agent/request (config only) -> log request/header -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' - agent/request-error(original error, consecutive retry attempt, signal) + agent/request-error(original error, failure facts, immutable prior failures, signal) retry in the next numbered step or preserve the original error otherwise: 'assistant/chunk' @@ -110,11 +110,11 @@ Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variab Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. -`dsh-compact-basic` handles pressure and canonical overflow at checkpoints; retry requires a balanced surface replacement ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). +`dsh-compact-basic` handles pressure/overflow; `dsh-llm-retry` handles bounded transient backoff. Independent budgets compose on `agent/request-error` ([recovery decision](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries -The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success. +The turn is the containment boundary. Adapter failures close the step, entering `agent/request-error` with the exact `Error`, `LlmFailure`, and retry history. Retry opens a numbered step; success clears history; exhaustion stores the failure on `turn/end`. Failed chunks commit no message or tool. Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. @@ -142,7 +142,7 @@ Durability is a plugin concern. Persistence backends buffer synchronous `session Messages contain typed blocks (`text`, `reasoning`, `tool-call`, `tool-result`) derived from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New block types coordinate adapters, UI bridges, compaction pricing, token metering, and persistence as one repo-wide contract; replay measurement types live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks (`block-start` through `finish`) and `BlockAssembler`. The loop logs and assembles chunks, storing provider/model provenance plus replay state. An `LlmAdapter` implements `stream()`, registers provider routes, and may expose selector metadata; it resolves and validates model ids. Replay state reaches targets only when both routes map to one adapter instance, which owns validation and conversion. The contract lives in [llm-streaming.md](core-data-structures/llm-streaming.md). +Streaming uses raw chunks and `BlockAssembler`. One `LlmAdapter.stream()` is one provider attempt; adapters report facts, while recovery policy lives on `agent/request-error`. The loop logs chunks and successful provenance/replay state. Remote adapters stop stalled transport with per-read idle watchdogs. Replay state reaches targets only when routes share an adapter instance ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 74d0c6ba7e..ea1b24ddf1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:207`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -66,6 +66,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable } ``` @@ -142,6 +144,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Bounded transient model-request retry policy. */ + llmRetry?: llmRetry.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -157,9 +161,9 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:60`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -237,6 +241,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ + llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -427,6 +433,8 @@ export interface Config { reasoningEffort?: 'high' | 'max' /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ + streamIdleTimeoutMs?: number } /** One optional model entry advertised by the hand-written adapter. */ @@ -440,7 +448,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -475,16 +483,14 @@ export interface PiAiProviderProfile { timeoutMs?: number /** WebSocket connection timeout in milliseconds. */ websocketConnectTimeoutMs?: number - /** Provider SDK retry count. */ - maxRetries?: number - /** Maximum provider-requested retry delay in milliseconds. */ - maxRetryDelayMs?: number + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number } ``` Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:40`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -530,6 +536,28 @@ export interface ReplayModelConfig { Source: [`packages/support/llm-replay/src/index.ts:375`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-llm-retry` + +Requires: `agents` + +```ts config-catalog +/** Deployment-owned limits and classification for transient request recovery. */ +export interface Config { + /** Maximum transient retries after the first request (default 2). */ + maxTransientRetries?: number + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] +} +``` + +Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) + ## `@deepseek-ai/dsh-mcp-client` Requires: `tools` @@ -822,7 +850,7 @@ export interface Config { } ``` -Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts) +Source: [`packages/ui/stdio/src/index.ts:34`](../packages/ui/stdio/src/index.ts) ## `@deepseek-ai/dsh-stdio-demo` @@ -864,6 +892,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable /** * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` @@ -1266,7 +1296,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..2e303739da 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -201,17 +201,18 @@ Recover a model-request failure after its failed step has closed. `retry` opens * @param turn - the open turn number. * @param step - the failed step number. * @param error - the original model-request failure. - * @param retryAttempt - zero-based number of prior recovery retries. + * @param failure - serializable facts normalized at the final adapter boundary. + * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -322,7 +323,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +344,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -473,7 +474,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:44`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..8064c5d2f2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -525,7 +525,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:137`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b87513e11..d08297a751 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -226,6 +226,22 @@ interface GenerateOptions { Why a model response stopped is a merge-extensible reason: +```ts type-equiv +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly retryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} +``` + ```ts type-equiv /** * Why a model response stopped. @@ -235,8 +251,8 @@ interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } 'max-tokens': { kind: 'max-tokens' } - 'aborted': { kind: 'aborted' } - 'error': { kind: 'error'; message: string; code?: string } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } } ``` @@ -445,14 +461,14 @@ type ContinuationDecision = | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` -`agent/request-error` receives the original `RequestError`, whose optional provider-neutral `code` supports stable routing without message parsing: +`agent/request-error` receives the exact original `RequestError` beside its immutable `LlmFailure`, an immutable list of failures that already authorized another request in the consecutive sequence, the turn signal, and `next()`. Recovery plugins route on `failure.code`, not the live error's message; each policy counts only its own codes, and a successful request clears the history: ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` preserves that error: +It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` retains the structured failure on `turn/end`: ```ts type-equiv /** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 41ce6427e6..1ecaa00943 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -31,18 +31,38 @@ type StreamChunk = } ``` +Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `retryAfterMs` is a validated positive delay observed at the provider boundary, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. + +```ts type-equiv +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly retryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} +``` + ## The adapter contract Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop closes the failed step and offers either form to `agent/request-error`; absent recovery it becomes a turn error, and no normal completed assistant message is logged for that request. +- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts with that call; the agent loop closes the failed step and offers the error, facts, and immutable prior-retried facts to `agent/request-error`. Absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. +- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. -This contract was pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter cannot throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not. +This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. ## `AppIdentity` — app attribution diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 263d991f86..1d8c040991 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -435,7 +435,7 @@ declare class Session { - `context/message` → a user-role message at its chronological position. The default `envelope` is `context`, which wraps content as ``; `envelope: 'raw'` uses caller-owned framing verbatim. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message wrapped in `` at its chronological position. -Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. +Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. ## Live-session fork API @@ -479,9 +479,13 @@ interface TurnEndReasonMap { * The turn failed: a step threw or the model reported a failure. `step` is the * step number the failure occurred on (the operational error's location — the * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). `code` is the error's code when one was attached. + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. */ - error: { kind: 'error'; step: number; message: string; code?: string } + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..8471a2b8f5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,24 +10,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e99a53c030..02424b6765 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,6 +18,7 @@ flowchart TD pkg_llm["llm"] pkg_llm_deepseek["llm-deepseek"] pkg_llm_pi_ai["llm-pi-ai"] + pkg_llm_retry["llm-retry"] pkg_token_meter["token-meter"] end subgraph group_core["packages/core"] @@ -157,7 +158,9 @@ flowchart TD pkg_scripts --> pkg_app_boot pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_timeout pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_llm pkg_session --> pkg_scope @@ -196,6 +199,10 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_timeout pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent @@ -318,6 +325,7 @@ flowchart TD pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_llm + pkg_acp --> pkg_llm_retry pkg_acp --> pkg_permission pkg_acp --> pkg_sandbox pkg_acp --> pkg_session @@ -380,11 +388,13 @@ flowchart TD pkg_stdio --> pkg_agent pkg_stdio --> pkg_agent_loop pkg_stdio --> pkg_llm + pkg_stdio --> pkg_llm_retry pkg_stdio --> pkg_session pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_llm + pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction @@ -393,6 +403,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry pkg_agent_spine_demo --> pkg_session pkg_agent_spine_demo --> pkg_skill pkg_agent_spine_demo --> pkg_skill_local @@ -466,8 +477,8 @@ flowchart TD | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | @@ -488,6 +499,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | @@ -517,7 +529,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | +| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | @@ -529,9 +541,9 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 688d3b545c..74f677d688 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) ### `bash/*` @@ -258,7 +258,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) ### `hook/*` @@ -306,6 +306,24 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) +### `llm/*` + +#### `llm/retry` — log-only + +```ts persistence-catalog +/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ +'llm/retry': { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmFailure +} +``` + +Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src/index.ts) + ### `permission/*` #### `permission/preset` — log-only @@ -336,7 +354,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts) ### `request/*` @@ -350,7 +368,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `steering/*` @@ -363,7 +381,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `step/*` @@ -374,7 +392,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +401,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) ### `todo/*` @@ -396,7 +414,7 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) ### `tool/*` @@ -413,7 +431,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -457,7 +475,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) ### `turn/*` @@ -474,7 +492,7 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -490,7 +508,7 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) ### `user/*` @@ -503,4 +521,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) diff --git a/docs/testing.md b/docs/testing.md index 84629aae45..d2260f9221 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,6 +17,8 @@ We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. Example: bridge tool-call tests run the scripted mock MODEL but the real tool + real executor (`makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`). +Recovery tests separate pre/post-chunk failures by step and prove failed chunks derive no message or tool side effect. Cover exhaustion, cancellation, policy composition, persistence, status, wire counts, transport-closing idle timeouts, and shipping Loader composition. + ## Verify the world, not the self-report An e2e assertion re-runs the command or re-reads the file externally; a keyword probe on the agent's own output lets a cheating agent pass. Assert untouched files are byte-identical. e2e tests own their resources: create the harness in the test, dispose in `afterEach` (even on failure/retry/timeout); shared fixtures live in a plain `tests/harness.ts`, never another `*.e2e.ts` (importing a spec re-registers its `describe` and duplicates real API calls). diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index f0ef4267ac..a069fb1ebe 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -4,4 +4,4 @@ {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"step/end","seq":4,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":5,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"message":"simulated provider error (HTTP 401)","code":"AUTH"}}} +{"type":"turn/end","seq":5,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"simulated provider error (HTTP 401)","code":"AUTH"}}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 540eb2338a..f941121f12 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n[Model attempt failed; any partial output above is discarded: simulated provider error (HTTP 401)]\n\n"}}}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index a0ee2b4036..56f512ba8d 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 5d325d57ba..2a958bff9d 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -93,9 +93,10 @@ export class BasicCompactService extends CompactService { } }) - ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { - if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || retryAttempt >= this.config.maxOverflowRetries + ctx.on('agent/request-error', async (agent, _turn, _step, _error, failure, priorFailures, signal, next) => { + const priorOverflowFailures = priorFailures.filter(item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE).length + if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE + || priorOverflowFailures >= this.config.maxOverflowRetries || signal.aborted) return next() let generation: number diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 62b5f5f5e2..32e308bce4 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -141,14 +141,10 @@ export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { /** Map a terminal summarization finish to its fail-closed error. */ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { - case 'error': { - const error = new Error(finish.message) as Error & { code?: string } - if (finish.code !== undefined) error.code = finish.code - return error - } + case 'error': case 'aborted': { - const error = new Error('summarization stream aborted') as Error & { code?: string } - error.code = 'ABORTED' + const error = new Error(finish.failure.message) as Error & { code?: string } + error.code = finish.failure.code return error } case 'max-tokens': { diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..14038a03e8 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -7,7 +7,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -762,9 +762,9 @@ describe('default one-shot summarizer', () => { }) it.each([ - [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], - [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], - [{ kind: 'aborted' }, 'ABORTED', /aborted/], + [{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/], + [{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/], + [{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/], [{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/], ] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) ( 'rejects terminal finish %#', @@ -802,7 +802,9 @@ describe('automatic listener and loader composition', () => { signal = SIGNAL, next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), ): Promise<{ action: 'fail' | 'retry' }> { - return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next) + const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } + const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) + return ctx.waterfall('agent/request-error', owner, 1, 1, error, failure, priorFailures, signal, next) } function overflow(message = 'provider overflow'): Error & { code: string } { diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 1327f073c5..e899979f46 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import * as LlmRetry from '@deepseek-ai/dsh-llm-retry' import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' /** @@ -61,7 +62,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { readonly conversationRequests: GenerateOptions[] = [] readonly summaryRequests: GenerateOptions[] = [] - constructor(private readonly delivery: 'thrown' | 'in-band') { + constructor( + private readonly delivery: 'thrown' | 'in-band', + private readonly transientAfterOverflow = false, + ) { super() } @@ -83,12 +87,17 @@ class OverflowRecoveryAdapter extends LlmAdapter { type: 'finish', reason: { kind: 'error', - message: 'request too large for model context', - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: 'request too large for model context', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, }, } return } + if (this.transientAfterOverflow && this.conversationRequests.length === 2) { + throw new LlmError('temporary provider outage', 'SERVER') + } yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } } yield { type: 'finish', reason: { kind: 'stop' } } @@ -134,6 +143,29 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } +function seedOverflowHistory(agent: Agent): void { + for (let turn = 1; turn <= 2; turn += 1) { + const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' + agent.session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + agent.session.append('user/message', { + content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + agent.session.append('step/start', { turn, step: 1 }) + agent.session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn, + step: 1, + content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn, step: 1 }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } +} + describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { it('uses the model actually routed by agent/request for post-step pressure', async () => { const { ctx } = await harness(8) @@ -241,26 +273,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }) - for (let turn = 1; turn <= 2; turn += 1) { - const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' - agent.session.append('turn/start', { - turn, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - agent.session.append('user/message', { - content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - agent.session.append('step/start', { turn, step: 1 }) - agent.session.append('assistant/message', { - provenance: { provider: 'mock', model: 'mock' }, - turn, - step: 1, - content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], - }, { surfaceOp: 'append' }) - agent.session.append('step/end', { turn, step: 1 }) - agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) - } + seedOverflowHistory(agent) agent.send([{ type: 'text', text: 'continue from history' }]) await agent.whenIdle() @@ -299,4 +312,47 @@ describe('context-overflow recovery across the real loop and compact-basic', () } }, ) + + it('keeps context-overflow and transient retry budgets independent in one sequence', async () => { + const ctx = new Context() + const adapter = new OverflowRecoveryAdapter('thrown', true) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(Invariants) + await ctx.plugin(LlmRetry, { + maxTransientRetries: 1, + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 0, + }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + ctx.llm.registerAdapter(['mock'], adapter) + await ctx.plugin(BasicCompactService, { + thresholdRatio: 1, + retainTokens: 100, + maxTokens: 64, + compactionRetries: 0, + maxOverflowRetries: 1, + }) + + try { + const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' }) + seedOverflowHistory(agent) + agent.send([{ type: 'text', text: 'continue from history' }]) + await agent.whenIdle() + + expect(adapter.conversationRequests).toHaveLength(3) + expect(adapter.summaryRequests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) + .toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) + expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step)) + .toEqual([1, 2, 3]) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..e6c4cf05cb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -672,8 +672,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param retryAttempt - zero-based number of prior recovery retries.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Recover a model-request failure after its failed step has closed.', }, { @@ -1114,7 +1114,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'FinishReasonMap', - declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n };\n \'error\': {\n kind: \'error\';\n message: string;\n code?: string;\n };\n}', + declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}', }, { name: 'FsDirEntry', @@ -1184,6 +1184,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmFailure', + declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly retryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', @@ -1220,6 +1224,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', }, + { + name: 'ProviderRequestId', + declaration: 'export type ProviderRequestId = Branded<\'ProviderRequestId\'>;', + }, { name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', @@ -1566,7 +1574,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'TurnTrigger', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 9c5c869168..6d70d00746 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -54,7 +54,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. @@ -63,6 +63,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) - Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` +- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: `session/event` + `session/flush` diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 9016c16d9b..926674f866 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -6,9 +6,9 @@ */ import type { Context } from 'cordis' -import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, llmFailureOf } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' @@ -28,24 +28,27 @@ function toError(error: unknown): RequestError { /** Distinguishes final model-request failures from failures in later step processing. */ class TerminalModelRequestFailure extends Error { - constructor(readonly requestError: RequestError) { + constructor( + readonly requestError: RequestError, + readonly failure: LlmFailure, + ) { super(requestError.message, { cause: requestError }) this.name = 'TerminalModelRequestFailure' } } /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): RequestError | undefined { +function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined { switch (finish.kind) { - case 'error': { - const error: RequestError = new Error(finish.message) - if (finish.code !== undefined) error.code = finish.code - return error - } + case 'error': case 'aborted': { - const error: RequestError = new Error('model stream aborted') - error.code = 'ABORTED' - return error + const facts = finish.failure + const error = new LlmError(facts.message, facts.code, { + ...facts.status === undefined ? {} : { status: facts.status }, + ...facts.retryAfterMs === undefined ? {} : { retryAfterMs: facts.retryAfterMs }, + ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, + }) + return { error, failure: error.failure } } // stop / tool-calls / max-tokens / plugin-added kinds → not a failure. default: @@ -191,7 +194,7 @@ async function runTurn( let reason: TurnEndReason = { kind: 'completed' } let step = 0 - let requestRetryAttempt = 0 + let requestFailureHistory: readonly LlmFailure[] = Object.freeze([]) let stepOpen = false let errorReported = false let terminalStopped = false @@ -204,10 +207,12 @@ async function runTurn( } // Record the durable turn failure once and contain the live error notification. - const failTurn = (err: RequestError): void => { + const failTurn = (err: RequestError, failure?: LlmFailure): void => { if (errorReported) return errorReported = true - reason = { kind: 'error', step, ...errorData(err) } + reason = failure === undefined + ? { kind: 'error', step, ...errorData(err) } + : { kind: 'error', step, failure } try { events.emit('agent/error', turn, step, err) } catch { @@ -353,14 +358,14 @@ async function runTurn( let stepOutcome: | { hadToolCalls: boolean; finish: FinishReason } - | { requestError: RequestError } + | { requestError: RequestError; failure: LlmFailure } | { error: RequestError } try { stepOutcome = await runStep( ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { if (error instanceof TerminalModelRequestFailure) { - stepOutcome = { requestError: error.requestError } + stepOutcome = { requestError: error.requestError, failure: error.failure } } else { stepOutcome = { error: toError(error) } } @@ -383,7 +388,7 @@ async function runTurn( try { recoveryDecision = await events.waterfall( 'agent/request-error', turn, step, stepOutcome.requestError, - requestRetryAttempt, abort.signal, + stepOutcome.failure, requestFailureHistory, abort.signal, () => Promise.resolve(defaultDecision), ) } catch (recoveryError: unknown) { @@ -404,10 +409,10 @@ async function runTurn( } switch (recoveryDecision.action) { case 'retry': - requestRetryAttempt += 1 + requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure]) continue case 'fail': - failTurn(stepOutcome.requestError) + failTurn(stepOutcome.requestError, stepOutcome.failure) break /* v8 ignore next -- closed-union exhaustiveness guard */ default: @@ -435,7 +440,7 @@ async function runTurn( break } - requestRetryAttempt = 0 + requestFailureHistory = Object.freeze([]) // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) @@ -635,13 +640,14 @@ async function runStep( assembler.push(chunk) } } catch (error: unknown) { - if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error) + const failure = llmFailureOf(stream, error) + if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) throw error } // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) - if (stepError) throw new TerminalModelRequestFailure(stepError) + if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure) const recordAssistantMessage = ( assembledContent: ContentBlock[], diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 80d085c32b..f79f917281 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' @@ -925,8 +925,15 @@ describe('discriminated SessionEvent narrows without casts', () => { describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { // A finish-error chunk must not produce a completed assistant turn. + const failure = { + message: 'provider 401', + code: 'AUTH', + status: 401, + retryAfterMs: 2_000, + requestId: ProviderRequestId('finish-request-1'), + } const errorStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } }, + { type: 'finish', reason: { kind: 'error', failure } }, ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) @@ -938,20 +945,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure }]) const events = [...agent.session.events] // The durable failure lives on turn/end.reason (with the failing step), not // a standalone error event. const turnEnd = events.find(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure }) // A failed step must not synthesize an assistant message. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => { const abortedStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'aborted' } }, + { type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } }, ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) @@ -963,13 +970,13 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }]) expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { const errorStream: StreamChunk[] = [ - { type: 'finish', reason: { kind: 'error', message: 'codeless failure' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } }, ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) @@ -981,7 +988,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () send(agent, 'go') await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }]) + expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }]) }) }) @@ -1101,7 +1108,7 @@ describe('turn and step boundary recovery', () => { }) it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => { - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) @@ -1131,7 +1138,7 @@ describe('turn and step boundary recovery', () => { const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', - message: 'provider failed', + failure: { message: 'provider failed', code: 'UNKNOWN' }, }) }) @@ -1167,7 +1174,7 @@ describe('turn and step boundary recovery', () => { it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { // Listener failure cannot interrupt error finalization or the next turn. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) @@ -1183,7 +1190,11 @@ describe('turn and step boundary recovery', () => { expect(c.turnStart).toBe(1) expect(c.turnEnd).toBe(1) expect(c.stepStart).toBe(c.stepEnd) - expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' }) + expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ + kind: 'error', + step: 1, + failure: { message: 'provider 500', code: 'SERVER' }, + }) // loop survives: a second turn runs to completion (invariants oracle would // throw on its turn/start if turn 1 had been left open). @@ -1328,7 +1339,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot interrupt error finalization', async () => { // Observer failure after step/end commit cannot interrupt turn finalization. - const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] + const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index a1d449d273..2c5cf06691 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -177,7 +177,9 @@ describe('toError normalization', () => { // String() of { code: 500 } is '[object Object]' expect(errors[0]!.message).toBe('[object Object]') const turnEnd = agent.session.events.find(e => e.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' + && ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)) + .toBe('UNKNOWN') }) }) @@ -208,7 +210,8 @@ describe('coded error data emission', () => { const turnEnd = agent.session.events.find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { - expect(turnEnd.data.reason.code).toBe('RATE_LIMIT') + expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code) + .toBe('RATE_LIMIT') } }) }) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index bfbcad23ba..72688b96bc 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -5,8 +5,9 @@ import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, + ProviderRequestId, } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' @@ -258,16 +259,17 @@ describe('agent post-step and request-error lifecycle', () => { it.each([ ['thrown', contextError()], - ['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]], + ['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] satisfies StreamChunk[]], ] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => { const adapter = new FailureScriptAdapter([failure, textResponse('recovered')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' }) const attempts: number[] = [] - ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => { + ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => { expect(subject).toBe(agent) expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) - attempts.push(attempt) + expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + attempts.push(history.length) subject.session.append('context/message', { content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], source: { kind: 'plugin', plugin: 'test-recovery' }, @@ -295,7 +297,7 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let recoveries = 0 install(ctx) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -326,7 +328,7 @@ describe('agent post-step and request-error lifecycle', () => { }) const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -359,7 +361,7 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -387,7 +389,7 @@ describe('agent post-step and request-error lifecycle', () => { } const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' }) let recoveries = 0 - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { recoveries += 1 return next() }) @@ -406,7 +408,7 @@ describe('agent post-step and request-error lifecycle', () => { const ctx = await harness(makeAdapter(original)) const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) let seen: Error | undefined - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { seen = error return next() }) @@ -417,12 +419,52 @@ describe('agent post-step and request-error lifecycle', () => { expect(seen).toBe(original) }) + it('passes structured facts beside the original Error and records them on exhaustion', async () => { + const original = new LlmError('provider busy', 'RATE_LIMIT', { + status: 429, + retryAfterMs: 2_000, + requestId: ProviderRequestId('req-9'), + }) + Object.freeze(original) + const ctx = await harness(new SynchronousDispatchFailureAdapter(original)) + const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' }) + let seenError: Error | undefined + let seenFailure: LlmFailure | undefined + let seenHistory: readonly LlmFailure[] | undefined + ctx.on('agent/request-error', async ( + _agent, _turn, _step, error, failure, history, _signal, next, + ) => { + seenError = error + seenFailure = failure + seenHistory = history + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seenError).toBe(original) + expect(seenFailure).toEqual({ + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + retryAfterMs: 2_000, + requestId: ProviderRequestId('req-9'), + }) + expect(seenHistory).toEqual([]) + expect(Object.isFrozen(seenHistory)).toBe(true) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', step: 1, failure: seenFailure } }, + }) + }) + it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => { for (const scenario of ['iterator', 'no-adapter'] as const) { const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness() const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' }) let seen = '' - ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => { seen = error.code ?? '' return next() }) @@ -436,14 +478,17 @@ describe('agent post-step and request-error lifecycle', () => { const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')]) const cappedCtx = await harness(capped) const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' }) - const cappedAttempts: number[] = [] - cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => { - cappedAttempts.push(attempt) - return attempt < 1 ? { action: 'retry' } : next() + const cappedHistories: string[][] = [] + cappedCtx.on('agent/request-error', async ( + _agent, _turn, _step, _error, _failure, history, _signal, next, + ) => { + const codes = history.map(entry => entry.code) + cappedHistories.push(codes) + return codes.length < 1 ? { action: 'retry' } : next() }) send(cappedAgent) await waitForIdle(cappedCtx, cappedAgent) - expect(cappedAttempts).toEqual([0, 1]) + expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]]) const reset = new FailureScriptAdapter([ contextError('first overflow'), @@ -458,14 +503,16 @@ describe('agent post-step and request-error lifecycle', () => { async execute() { return [{ type: 'text', text: 'worked' }] }, })) const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' }) - const resetAttempts: { step: number; attempt: number }[] = [] - resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => { - resetAttempts.push({ step, attempt }) - return resetAttempts.length === 1 ? { action: 'retry' } : next() + const resetHistories: { step: number; codes: string[] }[] = [] + resetCtx.on('agent/request-error', async ( + _agent, _turn, step, _error, _failure, history, _signal, next, + ) => { + resetHistories.push({ step, codes: history.map(entry => entry.code) }) + return resetHistories.length === 1 ? { action: 'retry' } : next() }) send(resetAgent) await waitForIdle(resetCtx, resetAgent) - expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }]) + expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }]) }) it('preserves the original provider error when recovery throws', async () => { @@ -479,7 +526,7 @@ describe('agent post-step and request-error lifecycle', () => { expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } }, }) }) @@ -489,7 +536,7 @@ describe('agent post-step and request-error lifecycle', () => { const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' }) let entered!: () => void const recoveryEntered = new Promise((resolve) => { entered = resolve }) - ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => { + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => { entered() await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c2b46f210d..4737180007 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). `PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..9f19113f0a 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -270,12 +270,13 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the failed step number. * @param error - the original model-request failure. - * @param retryAttempt - zero-based number of prior recovery retries. + * @param failure - serializable facts normalized at the final adapter boundary. + * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise /** * Override whether the turn continues. The default continues after tool * calls or steering and stops otherwise; a continue reason becomes steering. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a60be8da72..b67caed3ce 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -60,11 +60,11 @@ Durable values need one accepted representation, not a check followed by a secon ### Session event vocabulary (`types.ts`) -The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. -Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog. +Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. -Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). +Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. Every `SessionEvent` carries two optional top-level fields (structural metadata): diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index dc34760e9c..9565f97c88 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' import type { JsonValue } from './json.ts' /** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ @@ -102,9 +102,13 @@ export interface TurnEndReasonMap { * The turn failed: a step threw or the model reported a failure. `step` is the * step number the failure occurred on (the operational error's location — the * single durable record of an in-turn failure; live diagnostics also fire via - * `agent/error`). `code` is the error's code when one was attached. + * `agent/error`). Final model-request failures retain their normalized facts + * as one `failure`; other turn failures retain their live Error message/code. */ - error: { kind: 'error'; step: number; message: string; code?: string } + error: { kind: 'error'; step: number } & ( + | { failure: LlmFailure; message?: never; code?: never } + | { message: string; code?: string; failure?: never } + ) disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index a68fe145c3..d305124547 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -35,6 +35,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index d9baa96394..5453321d09 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -55,6 +55,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable } // Each front door owns a complete, directly readable config schema; extracting @@ -76,6 +78,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 9c431d5698..baa01c6819 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -17,6 +17,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events +@deepseek-ai/dsh-llm-retry bounded transient request retry policy @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash schema @@ -42,19 +43,21 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, llmRetry? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. + ## Model Experience -Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. +Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, which this bundle mounts without adding model-bound wrapper content. #### KV Cache effect diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 772d6c059b..2c73226f3c 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", @@ -48,6 +49,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 74ba5e0cc9..c8d5dd7d65 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -25,6 +25,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' +import * as llmRetry from '@deepseek-ai/dsh-llm-retry' import { resolveDshHome } from '@deepseek-ai/dsh-home' export const name = 'agent-spine-demo' @@ -77,6 +78,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Bounded transient model-request retry policy. */ + llmRetry?: llmRetry.Config } /** The skill config schema exported for app packages that forward `skills`. */ @@ -93,6 +96,9 @@ export const ToolBashConfigSchema: z = toolBash.Config /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config +/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ +export const LlmRetryConfigSchema: z = llmRetry.Config + /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -104,7 +110,8 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), - }) as unknown as z>, + llmRetry: LlmRetryConfigSchema, + }) as unknown as z>, ]) as unknown as z /** @@ -123,6 +130,7 @@ export function pickSpineConfig(config: Omit): Omit block.type === 'text' ? block.text : '').join('\n') ?? '' } +class TransientOnceAdapter extends LlmAdapter { + requests = 0 + + async * stream(_options: GenerateOptions): AsyncIterable { + this.requests += 1 + if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER') + yield* textResponse('recovered by bundled policy') + } +} + describe('dsh-agent-spine-demo bundle', () => { it('brings up the full default spine', async () => { const ctx = await mount({ workspaceContext: false }) @@ -117,6 +127,37 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('loads and configures bounded request recovery for every bundled front door', async () => { + const adapter = new TransientOnceAdapter() + const ctx = await mount({ + workspaceContext: false, + llmRetry: { + maxTransientRetries: 1, + initialDelayMs: 1, + maxDelayMs: 1, + jitterRatio: 0, + }, + }) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('bundled-retry-session'), + meta: { cwd: process.cwd() }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + handle.agent.send([{ type: 'text', text: 'recover' }]) + await waitForIdle(ctx, handle.agent) + + expect(adapter.requests).toBe(2) + const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') + expect(retryEvents).toHaveLength(1) + expect(retryEvents[0]?.data.retry).toBe(1) + expect(retryEvents[0]?.data.maxRetries).toBe(1) + expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') + await handle.dispose() + await ctx.fiber.dispose() + }) + it('includes the skill registry, local provider, and skill tool without builtin skills', async () => { const ctx = await mount({ workspaceContext: false }) @@ -370,6 +411,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: { enabled: false }, toolBash: { enableRunInBackground: false }, toolTasks: false as const, + llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -381,6 +423,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: appConfig.skills, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, + llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 89cb2accd8..9b897d1674 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../core/agent-loop" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../support/invariants" }, diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 760a0f60e1..ebd104fc8d 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -18,6 +18,7 @@ The package mounts no console logger, readline UI, user-interaction service, or | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | +| `llmRetry` | owner defaults | bounded transient model-request retry policy | | `persistenceRoot` | `./.sessions` | JSONL session root | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | @@ -40,7 +41,7 @@ Loader configs with bare package specifiers require `node --expose-internals` or ### Output formats - `text` writes the last assistant message containing text, followed by one newline. -- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn. +- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message. - `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index 68c9598e6c..a2b61f85f6 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -219,7 +219,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise let targetTurn: number | undefined let reason: TurnEndReason | undefined let result = '' - let usage: TokenUsage | undefined + const usageByStep = new Map() let outputError: Error | undefined let resolveTurn!: () => void let rejectTurn!: (error: Error) => void @@ -254,9 +254,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise targetTurn = event.data.turn } observe(session.id, event) + if (event.type === 'assistant/chunk' + && event.data.turn === targetTurn + && event.data.chunk.type === 'usage') { + usageByStep.set(event.data.step, event.data.chunk.usage) + } if (event.type === 'assistant/message' && event.data.turn === targetTurn) { result = assistantText(event) ?? result - if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage) + if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage) } if (event.type === 'turn/end' && event.data.turn === targetTurn) { reason = event.data.reason @@ -294,6 +299,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise } await ctx.sessions.flush(agent.session) if (outputError !== undefined) throw outputError + const usage = [...usageByStep.values()].reduce(addUsage, undefined) return { type: 'result', success: reason.kind === 'completed', @@ -365,7 +371,7 @@ export function formatTurnFailure(reason: TurnEndReason): string { switch (reason.kind) { case 'completed': return 'completed' case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}` - case 'error': return `failed at step ${reason.step}: ${reason.message}` + case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` case 'disposed': return 'was disposed' case 'max-tokens': return 'reached the model output-token limit' case 'rejected': return `was rejected: ${reason.reason}` diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index e5c77af9ed..d545b24f42 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -42,6 +42,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ + llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -62,6 +64,7 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + llmRetry: agentCore.LlmRetryConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index fe61a42304..dc9de2e6ef 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -70,6 +70,15 @@ function toolResponse(usage: TokenUsage): StreamChunk[] { ] } +function failedResponse(usage: TokenUsage): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'discarded' }, + { type: 'usage', usage }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } }, + ] +} + function reasoningResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'reasoning' }, @@ -98,6 +107,7 @@ async function harness(script: readonly ScriptEntry[]): Promise { persistenceRoot: root, skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, workspaceContext: false, + llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }) await new Promise(resolve => setTimeout(resolve, 80)) ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) @@ -323,6 +333,21 @@ describe('runOneShot and executeCli', () => { }) }) + it('counts a failed retry attempt once even though it has no assistant message', async () => { + const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 } + const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 } + const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)]) + + const result = await runOneShot(ctx, { task: 'task' }) + + expect(result.usage).toEqual({ + inputTokens: 18, + outputTokens: 7, + cacheReadTokens: 3, + reasoningTokens: 4, + }) + }) + it('keeps the prior text when a later assistant message has no text blocks', async () => { const { ctx } = await harness([ toolResponse({ inputTokens: 1, outputTokens: 1 }), @@ -463,6 +488,7 @@ describe('formatTurnFailure', () => { [{ kind: 'aborted' }, 'was aborted'], [{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'], [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], + [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], [{ kind: 'disposed' }, 'was disposed'], [{ kind: 'max-tokens' }, 'output-token limit'], [{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'], diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 2d706e3008..a3a8332486 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -36,6 +36,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | +| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | terminal banner / TUI subtitle | | `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 0bf66ab007..5e504e6804 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -99,6 +99,8 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable + /** Bounded transient model-request retry policy forwarded through agent-core. */ + llmRetry?: NonNullable /** * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` @@ -126,6 +128,7 @@ export const Config: z = z.object({ skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + llmRetry: agentCore.LlmRetryConfigSchema, resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) diff --git a/packages/llm/README.md b/packages/llm/README.md index ac08ffafc2..405c2f18f4 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -6,7 +6,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | +| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. +The interface lives at `llm/llm/`; adapters, retry policy, and reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 27bf4b626a..33af24b302 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -16,6 +16,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; high | max — omitted ⇒ not sent + streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek V4 Flash @@ -29,6 +30,8 @@ The plugin registers the single provider route `deepseek`. A request selects it `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. + ## App attribution Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. @@ -42,11 +45,11 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Connection failures are `TRANSPORT`; protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks. ## Testing -Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. +Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. ## Model Experience diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 1461ad0f44..02e8cb85ac 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -30,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 918c8eee82..b34cc49087 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,8 +5,9 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -33,6 +34,31 @@ export interface DeepSeekAdapterOptions { defaults?: RequestDefaults /** Advisory models exposed to discovery consumers; requests remain unrestricted. */ models?: readonly DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number +} + +/** Default maximum idle interval while an adapter stream read is outstanding. */ +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' + +function retryAfterMs(value: string | null): number | undefined { + if (value === null) return undefined + if (/^\d+$/.test(value)) { + const delay = Number(value) * 1_000 + return Number.isFinite(delay) && delay > 0 ? delay : undefined + } + const delay = Date.parse(value) - Date.now() + return Number.isFinite(delay) && delay > 0 ? delay : undefined +} + +function requestId(headers: Headers): ReturnType | undefined { + const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id') + return value === null || value.length === 0 ? undefined : ProviderRequestId(value) +} + +function errorMessage(value: unknown): string { + return value instanceof Error ? value.message : String(value) } /** @@ -43,9 +69,10 @@ export interface DeepSeekAdapterOptions { */ export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' + const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') + if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE if (status === 429) return 'RATE_LIMIT' if (status === 400) { - const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE return 'INVALID_REQUEST' } @@ -57,13 +84,22 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin * The first real `LlmAdapter`. One instance serves every model name it was * registered under (the harness model name IS the wire model name). * - * Abort: `options.signal` is handed to fetch — both the initial request and - * the body stream reject on abort, which surfaces to the loop as a rejected - * step (the loop already contains step errors). + * One stable signal reaches both initial fetch and body reads. Caller aborts + * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. */ export class DeepSeekAdapter extends LlmAdapter { + private readonly streamIdleTimeoutMs: number + constructor(private readonly options: DeepSeekAdapterOptions) { super() + this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(this.streamIdleTimeoutMs) + || this.streamIdleTimeoutMs <= 0 + || this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } } override providerInfo(provider: string): LlmProviderInfo { @@ -80,6 +116,48 @@ export class DeepSeekAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { + const consumer = new AbortController() + const upstream = options.signal === undefined + ? consumer.signal + : AbortSignal.any([options.signal, consumer.signal]) + using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) + const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]() + let exhausted = false + try { + while (true) { + const result = await watchdog.next(iterator) + if (result.done) { + exhausted = true + return + } + yield result.value + } + } catch (error: unknown) { + if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { + throw new LlmError( + `DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`, + 'TIMEOUT', + { cause: error }, + ) + } + if (options.signal?.aborted) { + throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) + } + if (error instanceof LlmError) throw error + throw new LlmError(`DeepSeek transport failed: ${errorMessage(error)}`, 'TRANSPORT', { cause: error }) + } finally { + consumer.abort('DeepSeek stream consumer stopped') + if (!exhausted && iterator.return !== undefined) { + try { + await iterator.return() + } catch (_abortedTransportTeardown) { + // The consumer controller already owns termination; a return-time abort cannot add a second outcome. + } + } + } + } + + private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) // TODO(http): adopt the Cordis HTTP service when shared transport configuration @@ -96,7 +174,7 @@ export class DeepSeekAdapter extends LlmAdapter { : {}, }, body: JSON.stringify(body), - ...options.signal ? { signal: options.signal } : {}, + signal, }) if (!response.ok) { @@ -110,7 +188,13 @@ export class DeepSeekAdapter extends LlmAdapter { // Only swallow error-body parsing: the HTTP status still identifies the // failure, so malformed gateway JSON must not mask it. } - throw new LlmError(message, httpErrorCode(response.status, providerError)) + const delay = retryAfterMs(response.headers.get('retry-after')) + const id = requestId(response.headers) + throw new LlmError(message, httpErrorCode(response.status, providerError), { + status: response.status, + ...delay === undefined ? {} : { retryAfterMs: delay }, + ...id === undefined ? {} : { requestId: id }, + }) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f9f223b6ff..c0d0df0f08 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -8,7 +8,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' -import { DeepSeekAdapter } from './adapter.ts' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' export { DeepSeekAdapter } from './adapter.ts' @@ -41,6 +42,8 @@ export interface Config { reasoningEffort?: 'high' | 'max' /** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */ models?: DeepSeekCatalogModel[] + /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ + streamIdleTimeoutMs?: number } const catalogModel: z = z.object({ @@ -55,6 +58,7 @@ export const Config: z = z.object({ thinking: z.union(['enabled', 'disabled']), reasoningEffort: z.union(['high', 'max']), models: z.array(catalogModel).default(DEFAULT_MODELS), + streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -92,5 +96,6 @@ export function apply(ctx: Context, config: Config): void { reasoningEffort: config.reasoningEffort, }, models: resolveModels(config.models), + streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, })) } diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index c66271246c..f0b5eaf789 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -35,7 +35,10 @@ export function mapFinishReason(reason: string): FinishReason { case 'length': return { kind: 'max-tokens' } default: // content_filter, insufficient_system_resource, future additions. - return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() } + return { + kind: 'error', + failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() }, + } } } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 954a0ecebd..2e23fd49f7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,14 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { + CONTEXT_WINDOW_EXCEEDED_CODE, + LlmError, + ProviderRequestId, + QUOTA_EXCEEDED_CODE, + userAgent, +} from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' @@ -12,7 +19,7 @@ import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ type Behavior = | { kind: 'sse'; events: string[]; delayMs?: number } - | { kind: 'http-error'; status: number; body: string; contentType?: string } + | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record } | { kind: 'close-early'; events: string[] } interface MockServer { @@ -30,6 +37,7 @@ const servers: Server[] = [] afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) vi.unstubAllEnvs() + vi.useRealTimers() }) /** Local chat-completions stand-in: replays scripted behaviors per request. */ @@ -48,7 +56,10 @@ async function mockServer(script: Behavior[]): Promise { return } if (behavior.kind === 'http-error') { - response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' }) + response.writeHead(behavior.status, { + 'content-type': behavior.contentType ?? 'application/json', + ...behavior.headers, + }) response.end(behavior.body) return } @@ -202,6 +213,84 @@ describe('DeepSeekAdapter against a mock server', () => { expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) }) + it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'slow down' } }), + headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, + }]) + const ctx = await harness(server.url) + let thrown: unknown + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toBeInstanceOf(LlmError) + expect((thrown as LlmError).failure).toEqual({ + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + retryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }) + }) + + it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { + const now = 1_800_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'come back later' } }), + headers: { + 'retry-after': new Date(now + 3_000).toUTCString(), + 'x-deepseek-request-id': 'deepseek-503', + }, + }]) + const ctx = await harness(server.url) + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + retryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) + } finally { + dateNow.mockRestore() + } + }) + + it('omits zero, non-finite, invalid, and past Retry-After values', async () => { + const values = [ + '0', + '9'.repeat(400), + 'not-a-date', + new Date(0).toUTCString(), + ] + for (const value of values) { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'retry later' } }), + headers: { 'retry-after': value }, + }]) + const ctx = await harness(server.url) + let thrown: LlmError | undefined + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + if (error instanceof LlmError) thrown = error + } + expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 }) + } + }) + it('classifies only context-capacity HTTP 400 details as context overflow', () => { expect(httpErrorCode(400, { message: 'request too large for model context' })) .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) @@ -210,6 +299,12 @@ describe('DeepSeekAdapter against a mock server', () => { expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') }) + it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { + expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' })) + .toBe(QUOTA_EXCEEDED_CODE) + expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT') + }) + it('keeps the status-line message for JSON error bodies without a message', async () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) @@ -272,7 +367,76 @@ describe('DeepSeekAdapter against a mock server', () => { })() setTimeout(() => { controller.abort() }, 30) - await expect(pending).rejects.toThrow() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + }) + + it('maps connection failures to TRANSPORT without losing the cause', async () => { + const cause = new TypeError('connection refused') + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) + const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('renders a non-Error transport rejection without losing its cause', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const failed = Promise.withResolvers() + failed.reject('offline') + return failed.promise + }) + const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ + message: 'DeepSeek transport failed: offline', + code: 'TRANSPORT', + cause: 'offline', + }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('aborts the underlying body when the stream stays idle past its watchdog', async () => { + vi.useFakeTimers() + let stopped = false + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + stopped = true + controller.error(signal.reason) + }, { once: true }) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const drain = (async () => { + for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ } + })() + const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(stopped).toBe(true) + } finally { + fetchSpy.mockRestore() + } }) }) @@ -419,4 +583,30 @@ describe('plugin registration and config', () => { expect(adapter).toBeInstanceOf(DeepSeekAdapter) await expect(adapter.listModels('deepseek')).resolves.toEqual([]) }) + + it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: Number.POSITIVE_INFINITY, + })).toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).toThrow(/streamIdleTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: 0, + })).rejects.toThrow(/streamIdleTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/streamIdleTimeoutMs/) + }) }) diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index e62cebc4af..4ae833dc4c 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -232,8 +232,7 @@ describe('mapFinishReason', () => { (wire) => { expect(mapFinishReason(wire)).toEqual({ kind: 'error', - message: `model stopped: ${wire}`, - code: wire.toUpperCase(), + failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() }, }) }, ) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index e9de391ba1..5e427d88b9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 06395d701c..bedfc5517d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -19,7 +19,7 @@ Configure credentials and deployment-specific transport settings per provider. O reasoning: high - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY - maxRetries: 2 + streamIdleTimeoutMs: 300000 - provider: openrouter apiKey: !!js process.env.OPENROUTER_API_KEY headers: @@ -30,7 +30,9 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. + +The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. ## Provider/model routing and replay @@ -43,7 +45,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state ## Vocabulary differences - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. -- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks. Provider-specific error text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. +- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. @@ -57,7 +59,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata ## Testing -Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. +Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`. ## Model Experience @@ -95,3 +97,4 @@ Recorded response content appends to the next request and does not invalidate it - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. +- **Retry policy is not an adapter option** — SDK retries are disabled so durable agent steps and `llm/retry` events own every visible attempt; direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c922467deb..2a5aed1d76 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -32,6 +33,7 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7f40c67da3..25240109bb 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -16,7 +16,9 @@ import type { } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { PiAiProviderProfile } from './config.ts' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { resolveProfiles } from './config.ts' +import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' @@ -48,8 +50,8 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { ...profile.transport === undefined ? {} : { transport: profile.transport }, ...profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs }, ...profile.websocketConnectTimeoutMs === undefined ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs }, - ...profile.maxRetries === undefined ? {} : { maxRetries: profile.maxRetries }, - ...profile.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: profile.maxRetryDelayMs }, + // The agent recovery layer owns visible attempts; one adapter call is one SDK attempt. + maxRetries: 0, } } @@ -68,11 +70,11 @@ function requestHeaders(headers: Readonly> | undefined): * request, so models need not be registered during the Cordis lifecycle. */ export class PiAiAdapter extends LlmAdapter { - private readonly profiles: ReadonlyMap + private readonly profiles: ReadonlyMap constructor(options: PiAiAdapterOptions) { super() - this.profiles = new Map(options.profiles.map(profile => [profile.provider, profile])) + this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } override listModels(provider: string): Promise { @@ -97,12 +99,12 @@ export class PiAiAdapter extends LlmAdapter { } const model = resolveModel(profile, options.model) - // Pi-ai has no iterator-return cancellation hook. Chain an internal signal - // and abort it when this generator exits so early consumers stop the HTTP stream. - const controller = new AbortController() - const onCallerAbort = (): void => { controller.abort(options.signal?.reason) } - if (options.signal?.aborted) controller.abort(options.signal.reason) - else options.signal?.addEventListener('abort', onCallerAbort, { once: true }) + const consumer = new AbortController() + const upstream = options.signal === undefined + ? consumer.signal + : AbortSignal.any([options.signal, consumer.signal]) + const streamIdleTimeoutMs = profile.streamIdleTimeoutMs + using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { const events = streamSimple(model, toPiContext(options), { @@ -110,15 +112,44 @@ export class PiAiAdapter extends LlmAdapter { ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - signal: controller.signal, + signal: watchdog.signal, // Profile headers are deployment-owned; attribution names are // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) - yield* toStreamChunks(events, model.contextWindow) + const iterator = toStreamChunks(events, model.contextWindow)[Symbol.asyncIterator]() + let exhausted = false + try { + while (true) { + const result = await watchdog.next(iterator) + const timeout = timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') + if (timeout !== undefined) throw timeout + if (result.done) { + exhausted = true + return + } + yield result.value + } + } finally { + if (!exhausted) { + consumer.abort('pi-ai stream consumer stopped') + try { + await iterator.return(undefined) + } catch (_abortedSdkTeardown) { + // The stable signal already owns SDK termination; return-time abort cannot add an outcome. + } + } + } + } catch (error: unknown) { + if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) { + throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error }) + } + if (options.signal?.aborted) { + throw new LlmError('pi-ai request aborted by caller', 'ABORTED', { cause: error }) + } + throw error } finally { - options.signal?.removeEventListener('abort', onCallerAbort) - controller.abort('consumer stopped streaming') + consumer.abort('pi-ai stream consumer stopped') } } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index f7570aff64..d5b5d70867 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -7,6 +7,10 @@ import { getProviders } from '@earendil-works/pi-ai' import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +/** Default maximum idle interval while an adapter stream read is outstanding. */ +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 /** Configuration for one pi-ai provider route. */ export interface PiAiProviderProfile { @@ -30,10 +34,14 @@ export interface PiAiProviderProfile { timeoutMs?: number /** WebSocket connection timeout in milliseconds. */ websocketConnectTimeoutMs?: number - /** Provider SDK retry count. */ - maxRetries?: number - /** Maximum provider-requested retry delay in milliseconds. */ - maxRetryDelayMs?: number + /** Maximum provider idle time while one stream read is outstanding. */ + streamIdleTimeoutMs?: number +} + +/** Validated profile with every adapter-owned default resolved. */ +export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile { + /** Positive finite provider-idle interval after defaulting. */ + streamIdleTimeoutMs: number } /** Plugin configuration: the non-empty provider profiles this instance owns. */ @@ -60,8 +68,7 @@ const profile = z.object({ transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), - maxRetries: z.natural(), - maxRetryDelayMs: z.natural(), + streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), }) /** Runtime schema for {@link Config}. */ @@ -75,11 +82,18 @@ export const Config: z = z.object({ * @param profiles - configured provider profiles. * @returns validated profiles in configuration order. */ -export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiProviderProfile[] { +export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] { if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile') const supported = new Set(getProviders()) const seen = new Set() return profiles.map((source) => { + const legacy = source as PiAiProviderProfile & { + maxRetries?: unknown + maxRetryDelayMs?: unknown + } + if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { + throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') + } if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`) if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`) @@ -89,9 +103,18 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiP if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`) } + const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS + if (!Number.isFinite(streamIdleTimeoutMs) + || streamIdleTimeoutMs <= 0 + || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } seen.add(source.provider) return { ...source, + streamIdleTimeoutMs, ...source.headers === undefined ? {} : { headers: { ...source.headers } }, ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, } diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index c1a85addf0..2c89d1e224 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -8,7 +8,7 @@ * @module dsh-llm-pi-ai/stream */ -import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' import { isContextOverflow } from '@earendil-works/pi-ai' import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai' @@ -30,9 +30,12 @@ export function mapUsage(usage: PiUsage): TokenUsage { function classifyPiAiError(message: string): string { if (/\b(?:401|403)\b/.test(message)) return 'AUTH' + if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT' if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' + if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' + if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)) return 'TRANSPORT' return 'PI_AI_ERROR' } @@ -52,8 +55,10 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) if (piAiOverflow || harnessOverflow) { return { kind: 'error', - message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, } } @@ -61,10 +66,13 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) case 'stop': return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } case 'toolUse': return { kind: 'tool-calls' } - case 'aborted': return { kind: 'aborted' } + case 'aborted': return { + kind: 'aborted', + failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' }, + } case 'error': { const text = message.errorMessage ?? 'pi-ai stream error' - return { kind: 'error', message: text, code: classifyPiAiError(text) } + return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } } } } } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51f78e7760..1b7445fa56 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -6,6 +6,7 @@ import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@ import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getModels } from '@earendil-works/pi-ai' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from '../src/config.ts' import { assemble } from './assemble.ts' @@ -14,6 +15,8 @@ interface MockServer { paths: string[] requests: unknown[] headers: IncomingMessage['headers'][] + readonly closedResponses: number + responseClosed: Promise } const servers: Server[] = [] @@ -23,11 +26,23 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) }) -async function mockServer(script: { status?: number; events?: string[]; body?: string; delayMs?: number }[]): Promise { +async function mockServer(script: { + status?: number + events?: string[] + body?: string + delayMs?: number + headers?: Record +}[]): Promise { const paths: string[] = [] const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] + let closedResponses = 0 + const responseClosed = Promise.withResolvers() const server = createServer((request: IncomingMessage, response: ServerResponse) => { + response.on('close', () => { + closedResponses += 1 + responseClosed.resolve(undefined) + }) let body = '' request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) request.on('end', () => { @@ -36,7 +51,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s headers.push(request.headers) const behavior = script.shift() ?? { status: 500, body: 'script exhausted' } if (behavior.status !== undefined && behavior.status !== 200) { - response.writeHead(behavior.status, { 'content-type': 'application/json' }) + response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers }) response.end(behavior.body ?? '{}') return } @@ -56,7 +71,14 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('no port') - return { url: `http://127.0.0.1:${address.port}`, paths, requests, headers } + return { + url: `http://127.0.0.1:${address.port}`, + paths, + requests, + headers, + responseClosed: responseClosed.promise, + get closedResponses() { return closedResponses }, + } } const textEvents = [ @@ -107,8 +129,7 @@ describe('PiAiAdapter provider routing', () => { transport: 'sse', timeoutMs: 5000, websocketConnectTimeoutMs: 3000, - maxRetries: 0, - maxRetryDelayMs: 10, + streamIdleTimeoutMs: 10_000, thinkingBudgets: { high: 2048 }, }) await assemble(ctx, { @@ -161,13 +182,35 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1`, maxRetries: 0 }], + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) expect(result.finish.kind).toBe('error') expect(server.paths).toEqual(['/v1/responses']) }) + it('forces one wire request for an SDK-retryable provider failure', async () => { + const server = await mockServer([ + { + status: 429, + headers: { 'retry-after-ms': '1' }, + body: JSON.stringify({ error: { message: 'retryable provider failure' } }), + }, + { status: 500, body: JSON.stringify({ error: { message: 'hidden SDK retry' } }) }, + { status: 500, body: JSON.stringify({ error: { message: 'second hidden SDK retry' } }) }, + ]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + }) + + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + + expect(result.finish).toMatchObject({ kind: 'error' }) + expect(server.paths).toEqual(['/v1/responses']) + }) + it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const ctx = new Context() @@ -178,7 +221,6 @@ describe('PiAiAdapter provider routing', () => { apiKey: 'test-key', baseURL: `${server.url}/api/projects/openai/openai/v1`, headers: { 'api-key': 'test-key', Authorization: '' }, - maxRetries: 0, }], }) const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) @@ -195,9 +237,10 @@ describe('PiAiAdapter provider routing', () => { [500, 'SERVER'], ] as const)('maps HTTP %s failures to %s', async (status, code) => { const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }]) - const ctx = await harness(server.url, { maxRetries: 0 }) + const ctx = await harness(server.url) const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', code }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code } }) + expect(server.paths).toEqual(['/chat/completions']) }) it('uses the resolved catalog context window for usage-based overflow detection', async () => { @@ -218,10 +261,29 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish).toEqual({ kind: 'error', - message: `pi-ai detected context overflow for model "${model.id}"`, - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: `pi-ai detected context overflow for model "${model.id}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, }) }) + + it('stops the SDK request when the adapter idle watchdog expires', async () => { + const server = await mockServer([{ events: textEvents, delayMs: 200 }]) + const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 }) + + await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) + .rejects.toMatchObject({ code: 'TIMEOUT' }) + await Promise.race([ + server.responseClosed, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100) + }), + ]) + + expect(server.paths).toEqual(['/chat/completions']) + expect(server.closedResponses).toBe(1) + }) }) describe('provider profile lifecycle', () => { @@ -280,16 +342,31 @@ describe('provider profile lifecycle', () => { expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/) }) - it('rejects negative or fractional stream tunables at schema validation', () => { + it.each(['maxRetries', 'maxRetryDelayMs'] as const)( + 'rejects removed profile field %s instead of silently restoring hidden SDK retries', + async (field) => { + const legacy = { provider: 'openai', [field]: 2 } + expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i) + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] })) + .rejects.toThrow(/removed.*agent recovery/i) + }, + ) + + it('rejects invalid stream tunables at plugin load', async () => { const invalid = [ { timeoutMs: -1 }, { websocketConnectTimeoutMs: -1 }, - { maxRetries: -1 }, - { maxRetries: 0.5 }, - { maxRetryDelayMs: -1 }, + { streamIdleTimeoutMs: 0 }, + { streamIdleTimeoutMs: Number.NaN }, + { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, ] for (const entry of invalid) { - expect(() => new LlmPiAi.Config({ providers: [{ provider: 'openai', ...entry }] })).toThrow() + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] })) + .rejects.toThrow() } }) @@ -301,11 +378,59 @@ describe('provider profile lifecycle', () => { })()).rejects.toMatchObject({ code: 'NO_ADAPTER' }) expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) + + it('validates direct-constructor profiles at the embedding boundary', () => { + expect(() => new PiAiAdapter({ + profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], + })).toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => new PiAiAdapter({ + profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }], + })).toThrow(/streamIdleTimeoutMs.*no greater/) + }) }) describe('abort wiring', () => { + it('preserves an unknown pre-dispatch adapter Error exactly', async () => { + const original = new Error('SDK context conversion exploded') + const message = Object.defineProperty({}, 'role', { + get() { throw original }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [message as never], + })) { /* drain */ } + } + + await expect(drain()).rejects.toBe(original) + }) + + it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => { + const controller = new AbortController() + const original = new Error('conversion lost its caller') + const message = Object.defineProperty({}, 'role', { + get() { + controller.abort('caller cancelled during conversion') + throw original + }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [message as never], + signal: controller.signal, + })) { /* drain */ } + } + + await expect(drain()).rejects.toMatchObject({ code: 'ABORTED', cause: original }) + }) + it('resolves catalog endpoints without an override before honoring pre-abort', async () => { - const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key', maxRetries: 0 }] }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) const controller = new AbortController() controller.abort('already stopped') const chunks = [] diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index a2f37ce511..e33f0bf09a 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -485,20 +485,32 @@ describe('toStreamChunks', () => { ))) expect(chunks).toEqual([ { type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } }, - { type: 'finish', reason: { kind: 'error', message: 'boom', code: 'PI_AI_ERROR' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'PI_AI_ERROR' } } }, ]) }) it('maps aborted error events to aborted finish', async () => { const error = assistant({ stopReason: 'aborted' }) const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error }))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } }) + expect(chunks.at(-1)).toEqual({ + type: 'finish', + reason: { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }, + }) }) it('rejects a stream that ends without done or error', async () => { await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() })))) .rejects.toThrow(/without done\/error/) }) + + it('preserves an unknown SDK iterator Error exactly', async () => { + const original = Object.assign(new Error('SDK transport exploded'), { code: 'ECONNRESET' }) + async function* failedSdkStream(): AsyncGenerator { + throw original + } + + await expect(collect(toStreamChunks(failedSdkStream()))).rejects.toBe(original) + }) }) describe('mapStopReason / mapUsage', () => { @@ -506,46 +518,52 @@ describe('mapStopReason / mapUsage', () => { ['stop', { kind: 'stop' }], ['length', { kind: 'max-tokens' }], ['toolUse', { kind: 'tool-calls' }], - ['aborted', { kind: 'aborted' }], + ['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }], ] as const)('maps %s', (stopReason, expected) => { expect(mapStopReason(assistant({ stopReason }))).toEqual(expected) }) it('defaults the error message when pi-ai omits it', () => { expect(mapStopReason(assistant({ stopReason: 'error' }))) - .toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' }) + .toEqual({ kind: 'error', failure: { message: 'pi-ai stream error', code: 'PI_AI_ERROR' } }) }) it('maps routable HTTP-ish error messages to stable codes', () => { expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' }))) - .toMatchObject({ kind: 'error', code: 'AUTH' }) + .toMatchObject({ kind: 'error', failure: { code: 'AUTH' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' }))) - .toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + .toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: insufficient_quota' }))) + .toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' }))) - .toMatchObject({ kind: 'error', code: 'SERVER' }) + .toMatchObject({ kind: 'error', failure: { code: 'SERVER' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'provider timed out' }))) + .toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } }) + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ECONNRESET socket closed' }))) + .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 400: input exceeds the model context window limit', - }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 400: request too large for model context', - }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value', - }))).toMatchObject({ kind: 'error', code: 'INVALID_REQUEST' }) + }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) }) it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => { expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'prompt is too long: 213462 tokens > 200000 maximum', - }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + }))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ThrottlingException: Too many tokens, rate limit reached', - }))).toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + }))).toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } }) }) it('uses the resolved context window for silent and length-stop overflows', () => { @@ -553,15 +571,17 @@ describe('mapStopReason / mapUsage', () => { expect(mapStopReason(silent)).toEqual({ kind: 'stop' }) expect(mapStopReason(silent, 100)).toEqual({ kind: 'error', - message: 'pi-ai detected context overflow for model "deepseek-v4-flash"', - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { + message: 'pi-ai detected context overflow for model "deepseek-v4-flash"', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, }) const truncated = assistant({ stopReason: 'length', usage: usage(80, 0, 19) }) expect(mapStopReason(truncated)).toEqual({ kind: 'max-tokens' }) expect(mapStopReason(truncated, 100)).toMatchObject({ kind: 'error', - code: CONTEXT_WINDOW_EXCEEDED_CODE, + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, }) }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 107c12d264..06154a8a5e 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -70,7 +70,7 @@ function textOf(result: AssembledResult): string { function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void { if (result.finish.kind === 'error') { - throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`) + throw new Error(`provider request failed (${result.finish.failure.code}): ${result.finish.failure.message}`) } expect(result.finish.kind).toBe(expected) } diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts new file mode 100644 index 0000000000..e85c44c110 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const streamSimple = vi.hoisted(() => vi.fn()) + +vi.mock('@earendil-works/pi-ai', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, streamSimple } +}) + +import { PiAiAdapter } from '../src/adapter.ts' + +afterEach(() => { streamSimple.mockReset() }) + +describe('pi-ai SDK retry boundary', () => { + it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => { + const failure = new Error('mock SDK boundary') + streamSimple.mockReturnValue({ + async * [Symbol.asyncIterator](): AsyncGenerator { + throw failure + }, + }) + const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'openai', + model: 'gpt-4.1', + messages: [], + })) { /* drain */ } + } + + await expect(drain()).rejects.toBe(failure) + expect(streamSimple).toHaveBeenCalledOnce() + expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 }) + }) +}) diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index e9de391ba1..5e427d88b9 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md new file mode 100644 index 0000000000..baebc2d0b3 --- /dev/null +++ b/packages/llm/llm-retry/README.md @@ -0,0 +1,39 @@ +# `@deepseek-ai/dsh-llm-retry` + +Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. + +The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid provider `retryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. + +Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. + +```yaml +- name: '@deepseek-ai/dsh-llm-retry' + config: + maxTransientRetries: 2 + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] +``` + +## Model Experience + +### Transient request recovery + +#### What the model sees + +No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages. + +#### Token effect + +Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens. + +#### KV Cache effect + +The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity. + +## Known Limitations and Deferred Work + +- **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. +- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior. +- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json new file mode 100644 index 0000000000..fce40dbe33 --- /dev/null +++ b/packages/llm/llm-retry/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-llm-retry", + "description": "Bounded transient LLM request retry policy for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts new file mode 100644 index 0000000000..d4c5b47b3d --- /dev/null +++ b/packages/llm/llm-retry/src/index.ts @@ -0,0 +1,211 @@ +/** + * Bounded transient model-request retry policy on the agent loop's closed-step + * recovery seam. Each scheduled retry is durable before its cancellable wait. + * + * @module @deepseek-ai/dsh-llm-retry + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import type { LlmFailure } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ + 'llm/retry': { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: LlmFailure + } + } +} + +export const name = 'llm-retry' +export const inject = ['agents'] + +const DEFAULT_MAX_TRANSIENT_RETRIES = 2 +const DEFAULT_INITIAL_DELAY_MS = 500 +const DEFAULT_MAX_DELAY_MS = 10_000 +const DEFAULT_JITTER_RATIO = 0.1 +const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) + +/** Deployment-owned limits and classification for transient request recovery. */ +export interface Config { + /** Maximum transient retries after the first request (default 2). */ + maxTransientRetries?: number + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] +} + +/** Runtime schema for {@link Config}. */ +export const Config: z = z.object({ + maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES), + initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), + maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), + jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), + retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), +}) + +interface ResolvedConfig { + readonly maxTransientRetries: number + readonly initialDelayMs: number + readonly maxDelayMs: number + readonly jitterRatio: number + readonly retryableCodes: ReadonlySet +} + +function resolveConfig(config: Config): ResolvedConfig { + const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES + const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS + const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS + const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO + const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] + + if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) { + throw new Error('llm-retry: maxTransientRetries must be a non-negative integer') + } + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs') + } + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error('llm-retry: jitterRatio must be between 0 and 1') + } + if (codes.length === 0) { + throw new Error('llm-retry: retryableCodes must not be empty') + } + if (codes.some(code => code.length === 0)) { + throw new Error('llm-retry: retryableCodes must contain only non-empty strings') + } + if (new Set(codes).size !== codes.length) { + throw new Error('llm-retry: retryableCodes must not contain duplicates') + } + + return Object.freeze({ + maxTransientRetries, + initialDelayMs, + maxDelayMs, + jitterRatio, + retryableCodes: new Set(codes), + }) +} + +/** Non-serializable seams used to make timing policy deterministic in tests. */ +export interface RetryInternals { + /** Random sample in the inclusive zero-to-one range used for jitter. */ + random?: () => number +} + +function localDelay(config: ResolvedConfig, retry: number, random: () => number): number { + const exponent = Math.min(retry - 1, 1024) + const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs) + const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random() + return Math.min(exponential * jitter, config.maxDelayMs) +} + +function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false) + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, delayMs) + function onAbort(): void { + clearTimeout(timer) + resolve(false) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** + * Install bounded transient request recovery. + * @param ctx - plugin context that owns the listener and active waits. + * @param config - retry budget, delay bounds, jitter, and eligible codes. + * @param internals - non-serializable deterministic seams for tests. + */ +export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { + const resolved = resolveConfig(config) + const random = internals.random ?? Math.random + const lifetime = new AbortController() + const active = new Set>() + + async function backoff( + agent: Agent, + turn: number, + step: number, + failure: LlmFailure, + retry: number, + delayMs: number, + signal: AbortSignal, + ): Promise { + const fusedSignal = AbortSignal.any([signal, lifetime.signal]) + if (fusedSignal.aborted) return { action: 'fail' } + agent.session.append('llm/retry', { + turn, + step, + retry, + maxRetries: resolved.maxTransientRetries, + delayMs, + failure, + }) + if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' } + return { action: 'retry' } + } + + const disposeListener = ctx.on('agent/request-error', ( + agent: Agent, + turn: number, + step: number, + _error: RequestError, + failure: LlmFailure, + priorFailures: readonly LlmFailure[], + signal: AbortSignal, + next: () => Promise, + ) => { + // A waterfall may have captured this callback before its registration was + // removed. Lifetime cancellation must prevent that stale callback from + // entering a downstream policy after disposal. + if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) + if (!resolved.retryableCodes.has(failure.code)) return next() + const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length + if (priorTransientFailures >= resolved.maxTransientRetries) return next() + + const retry = priorTransientFailures + 1 + let delayMs: number + if (failure.retryAfterMs !== undefined && Number.isFinite(failure.retryAfterMs) && failure.retryAfterMs > 0) { + if (failure.retryAfterMs > resolved.maxDelayMs) return next() + delayMs = failure.retryAfterMs + } else { + delayMs = localDelay(resolved, retry, random) + } + + const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal) + .finally(() => active.delete(tracked)) + active.add(tracked) + return tracked + }) + + ctx.effect(() => async () => { + disposeListener() + lifetime.abort(new Error('llm-retry plugin disposed')) + await Promise.allSettled([...active]) + }, 'llm-retry: abort and drain backoffs') +} diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..c00175b20d --- /dev/null +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as retry from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +class TransientOnceAdapter extends LlmAdapter { + requests = 0 + + async * stream(_options: GenerateOptions): AsyncIterable { + this.requests += 1 + if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER') + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'recovered' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +async function loadYaml(lines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...lines, ''].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-llm-retry', retry], + ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +describe('real Loader composition', () => { + it('loads the flat policy and records recovery through the shipping loop', async () => { + const loaded = await loadYaml([ + "- name: '@deepseek-ai/dsh-llm'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-llm-retry'", + ' config:', + ' maxTransientRetries: 1', + ' initialDelayMs: 1', + ' maxDelayMs: 1', + ' jitterRatio: 0', + ' retryableCodes: [RATE_LIMIT, SERVER]', + "- name: '@deepseek-ai/dsh-agent-loop'", + ]) + + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + expect(loaded.agents).toBeInstanceOf(AgentRegistry) + + const adapter = new TransientOnceAdapter() + loaded.llm.registerAdapter(['mock'], adapter) + const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(loaded, agent) + agent.send([{ type: 'text', text: 'recover' }]) + await idle + + expect(adapter.requests).toBe(2) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + }) + }) +}) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts new file mode 100644 index 0000000000..3e5a9c8dcd --- /dev/null +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import type {} from '../src/index.ts' + +const dirs: string[] = [] + +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +async function backend(kind: 'jsonl' | 'sqlite'): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + if (kind === 'jsonl') { + const root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-jsonl-')) + dirs.push(root) + await ctx.plugin(SessionPersistenceJsonl, { root }) + } else { + await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' }) + } + return ctx +} + +describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) => { + it('round-trips the event losslessly without adding a model message', async () => { + const ctx = await backend(kind) + try { + const session = ctx.sessions.create(SessionId(`retry-${kind}`)) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('step/end', { turn: 1, step: 1 }) + const event = session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 750, + failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled in backoff' } }) + + expect(session.deriveMessages()).toEqual([]) + await ctx.sessions.flush(session) + const loaded = await ctx.sessionPersistence.load(session.id) + + expect(loaded.events.find(item => item.type === 'llm/retry')).toEqual(event) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts new file mode 100644 index 0000000000..bc10e28922 --- /dev/null +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -0,0 +1,453 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import * as retry from '../src/index.ts' + +type ScriptEntry = Error | Iterable | AsyncIterable + +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly entries: ScriptEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.entries.shift() + if (entry === undefined) throw new Error('retry test script exhausted') + if (entry instanceof Error) throw entry + yield* entry + } +} + +async function* partialToolFailure(error: Error): AsyncGenerator { + const id = CallId('discarded-call') + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'discarded partial output' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'discarded partial output' } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 1, id, name: 'danger', argumentsDelta: '{}' } + yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'danger', arguments: '{}' } } + throw error +} + +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +async function harness( + adapter: LlmAdapter, + config: retry.Config = {}, + beforeRetry?: (ctx: Context) => void, + internals: retry.RetryInternals = {}, +): Promise<{ ctx: Context; retryFiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + beforeRetry?.(ctx) + const resolvedConfig = Object.assign({ + maxTransientRetries: 2, + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + }, config) + const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { + retry.apply(inner, resolvedConfig, internals) + }, { inject: retry.inject })) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return { ctx, retryFiber } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function waitForRetry(ctx: Context, agent: Agent, retryNumber: number): Promise> { + return new Promise((resolve) => { + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry' && event.data.retry === retryNumber) { + dispose() + resolve(event) + } + }) + }) +} + +let context: Context | undefined + +afterEach(async () => { + vi.useRealTimers() + await context?.fiber.dispose() + context = undefined +}) + +describe('bounded transient retry policy', () => { + it('records the scheduled delay before opening a fresh request attempt', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('busy', 'RATE_LIMIT', { status: 429 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-success'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = new Promise>((resolve) => { + const dispose = context?.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry') { + dispose?.() + resolve(event) + } + }) + }) + + agent.send([{ type: 'text', text: 'go' }]) + const event = await scheduled + + expect(event.data).toEqual({ + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'busy', code: 'RATE_LIMIT', status: 429 }, + }) + expect(adapter.requests).toHaveLength(1) + await vi.advanceTimersByTimeAsync(499) + expect(adapter.requests).toHaveLength(1) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data.step)) + .toEqual([1, 2]) + expect(agent.session.deriveMessages().at(-1)).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + provenance: { provider: 'mock', model: 'mock' }, + }) + }) + + it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + partialToolFailure(new LlmError('stream interrupted', 'TRANSPORT')), + textResponse('recovered'), + ]) + ;({ ctx: context } = await harness(adapter)) + let toolExecutions = 0 + context.tools.register(defineTool({ + name: 'danger', + description: 'must not run for a failed provider attempt', + parameters: {}, + async execute() { + toolExecutions += 1 + return [{ type: 'text', text: 'unexpected' }] + }, + })) + const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(500) + await idle + + const failedChunks = agent.session.events.filter(event => + event.type === 'assistant/chunk' && event.data.step === 1, + ) + expect(failedChunks).toHaveLength(6) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) + expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) + expect(toolExecutions).toBe(0) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'recovered' }], + provenance: { provider: 'mock', model: 'mock' }, + }) + }) + + it('applies bounded exponential jitter and stops after the configured budget', async () => { + vi.useFakeTimers() + const samples = [0, 1] + const adapter = new ScriptedAdapter([ + new LlmError('busy one', 'SERVER'), + new LlmError('busy two', 'SERVER'), + new LlmError('busy three', 'SERVER'), + ]) + ;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, { + random: () => samples.shift() ?? 0.5, + })) + const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) + const first = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await first).data.delayMs).toBe(450) + + const second = waitForRetry(context, agent, 2) + await vi.advanceTimersByTimeAsync(450) + expect((await second).data.delayMs).toBe(1_100) + + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1_100) + await idle + + expect(adapter.requests).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { message: 'busy three', code: 'SERVER' } } }, + }) + }) + + it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => { + vi.useFakeTimers() + const accepted = new ScriptedAdapter([ + new LlmError('wait', 'RATE_LIMIT', { retryAfterMs: 2_000 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) + const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, acceptedAgent, 1) + acceptedAgent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(2_000) + const acceptedIdle = waitForIdle(context, acceptedAgent) + await vi.advanceTimersByTimeAsync(2_000) + await acceptedIdle + expect(accepted.requests).toHaveLength(2) + + await context.fiber.dispose() + const rejected = new ScriptedAdapter([ + new LlmError('wait too long', 'RATE_LIMIT', { retryAfterMs: 10_001 }), + ]) + ;({ ctx: context } = await harness(rejected)) + const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) + const rejectedIdle = waitForIdle(context, rejectedAgent) + rejectedAgent.send([{ type: 'text', text: 'go' }]) + await rejectedIdle + expect(rejected.requests).toHaveLength(1) + expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + }) + + it('delegates non-transient failures without scheduling a timer', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await idle + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('aborts and drains a captured backoff before plugin disposal completes', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'TRANSPORT'), + textResponse('must not run'), + ]) + const mounted = await harness(adapter) + context = mounted.ctx + const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + + await mounted.retryFiber.dispose() + await idle + await vi.advanceTimersByTimeAsync(60_000) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not make plugin disposal wait for a delegated recovery policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + context.on('agent/request-error', () => { + entered.resolve(undefined) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await entered.promise + + const disposing = mounted.retryFiber.dispose() + let timer: ReturnType | undefined + const outcome = await Promise.race([ + disposing.then(() => 'disposed' as const), + new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }), + ]) + if (timer !== undefined) clearTimeout(timer) + downstream.resolve({ action: 'fail' }) + await disposing + await idle + + expect(outcome).toBe('disposed') + expect(adapter.requests).toHaveLength(1) + }) + + it('fails a captured callback after disposal without entering downstream policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const captured = Promise.withResolvers() + let invokeCaptured: (() => Promise) | undefined + const mounted = await harness(adapter, {}, (ctx) => { + ctx.on('agent/request-error', (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + return new Promise((resolve) => { + invokeCaptured = async () => { resolve(await next()) } + captured.resolve(undefined) + }) + }) + }) + context = mounted.ctx + let downstreamCalls = 0 + context.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => { + downstreamCalls += 1 + return next() + }) + const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await captured.promise + + await mounted.retryFiber.dispose() + if (invokeCaptured === undefined) throw new Error('request-error waterfall did not capture retry callback') + await invokeCaptured() + await idle + + expect(downstreamCalls).toBe(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('lets turn cancellation win during backoff without opening another step', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'TIMEOUT'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) + const scheduled = waitForRetry(context, agent, 1) + agent.send([{ type: 'text', text: 'go' }]) + await scheduled + const idle = waitForIdle(context, agent) + agent.cancel('user cancelled during retry') + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted', reason: 'user cancelled during retry' } }, + }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets an earlier recovery listener cancel before retry policy runs', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter, {}, (ctx) => { + ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { + agent.cancel('cancelled by earlier recovery policy') + return next() + }) + })) + const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted', reason: 'cancelled by earlier recovery policy' } }, + }) + }) + + it('handles synchronous cancellation from the retry status event', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('temporary', 'SERVER'), + textResponse('must not run'), + ]) + ;({ ctx: context } = await harness(adapter)) + const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) + context.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'llm/retry') agent.cancel('cancelled by retry observer') + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + [{ maxTransientRetries: -1 }, /maxTransientRetries/], + [{ maxTransientRetries: 1.5 }, /maxTransientRetries/], + [{ initialDelayMs: 0 }, /initialDelayMs/], + [{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/], + [{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/], + [{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/], + [{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/], + [{ jitterRatio: 1.1 }, /jitterRatio/], + [{ retryableCodes: [] }, /must not be empty/], + [{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], + [{ retryableCodes: [''] }, /non-empty strings/], + ] as const)('fails direct composition for invalid config %#', (config, message) => { + expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message) + }) +}) diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json new file mode 100644 index 0000000000..44310af6e9 --- /dev/null +++ b/packages/llm/llm-retry/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../util/timeout" + } + ] +} diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index f9142dbc52..ff5bb6e0a2 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. -`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. @@ -21,12 +21,12 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re | Event | Mode | Purpose | |---|---|---| -| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) | +| `llm/stream` | waterfall | Intercept/wrap every streaming model call for caching, logging, or routing | ### Extension points - Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models. -- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. +- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -47,8 +47,9 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error. - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. +- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits. ### Real adapters @@ -64,7 +65,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel ## Known Limitations and Deferred Work -- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. +- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index 745cbbdc64..b2189fdaf9 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -5,10 +5,10 @@ */ import { HarnessError } from './error.ts' -import type { StreamChunk } from './types.ts' +import type { LlmFailure, StreamChunk } from './types.ts' -/** Errors proven to originate in one model call's final adapter boundary. */ -export type AdapterFailureScope = WeakSet +/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */ +export type AdapterFailureScope = WeakMap /** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ const adapterFailureScopes = new WeakMap, AdapterFailureScope>() @@ -47,10 +47,70 @@ export function markLlmAdapterFailure( const error = value instanceof Error ? value as Error & { code?: string } : new HarnessError(String(value), 'UNKNOWN', { cause: value }) - failures.add(error) + const carried = error instanceof HarnessError ? ownFailureSnapshot(error) : undefined + const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({ + message: errorMessage(error), + code: harnessErrorCode(error), + }) + failures.set(error, failure) return error } +/** Snapshot an own data property without invoking an SDK-defined accessor. */ +function ownFailureSnapshot(error: Error): LlmFailure | undefined { + try { + const descriptor = Object.getOwnPropertyDescriptor(error, 'failure') + return descriptor !== undefined && 'value' in descriptor + ? failureSnapshot(descriptor.value) + : undefined + } catch (_sdkPropertyTrap) { + return undefined + } +} + +/** Validate and detach an arbitrary serializable failure payload. */ +function failureSnapshot(value: unknown): LlmFailure | undefined { + if (typeof value !== 'object' || value === null) return undefined + try { + const candidate = value as Partial + const message = candidate.message + const code = candidate.code + const status = candidate.status + const retryAfterMs = candidate.retryAfterMs + const requestId = candidate.requestId + if (typeof message !== 'string' || message.length === 0 + || typeof code !== 'string' || code.length === 0 + || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599)) + || (retryAfterMs !== undefined && (!Number.isFinite(retryAfterMs) || retryAfterMs <= 0)) + || (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0))) return undefined + return Object.freeze({ + message, + code, + ...status === undefined ? {} : { status }, + ...retryAfterMs === undefined ? {} : { retryAfterMs }, + ...requestId === undefined ? {} : { requestId }, + }) + } catch (_sdkFailureGetter) { + return undefined + } +} + +/** Read an SDK error message without letting an accessor replace the primary failure. */ +function errorMessage(error: Error): string { + try { + const message: unknown = error.message + if (typeof message === 'string' && message.length > 0) return message + } catch (_sdkMessageGetter) { + // The fallback below preserves a serializable failure beside the original Error. + } + return 'LLM adapter failed' +} + +/** Trust only Harness-owned codes; third-party SDK codes are not our taxonomy. */ +function harnessErrorCode(error: Error): string { + return error instanceof HarnessError ? error.code : 'UNKNOWN' +} + /** * Whether a failure came from final adapter dispatch, iterator construction, * or iteration for the call represented by the exact returned stream handle. @@ -65,3 +125,18 @@ export function isLlmAdapterFailure( const failures = adapterFailureScopes.get(stream) return value instanceof Error && failures !== undefined && failures.has(value) } + +/** + * Retrieve normalized provider facts only for an Error tagged by this exact + * model call's final adapter boundary. + * @param stream - the exact stream returned to the consumer. + * @param value - the caught failure. + * @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures. + */ +export function llmFailureOf( + stream: AsyncIterable, + value: unknown, +): LlmFailure | undefined { + const failures = adapterFailureScopes.get(stream) + return value instanceof Error ? failures?.get(value) : undefined +} diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index ee1cf786b1..259dc49bce 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -1,5 +1,6 @@ /** - * dsh-llm's owned branded id: `CallId` (tool-call correlation). + * dsh-llm's owned branded ids: tool-call correlation and provider request + * diagnostics. * * The `Branded` primitive itself lives in `@deepseek-ai/dsh-brand` (a * zero-dependency type-only package) so every owner of a cross-boundary id can @@ -25,3 +26,15 @@ export type CallId = Branded<'CallId'> export function CallId(id: string): CallId { return id as CallId } + +/** Provider-issued request identifier retained for diagnostics across package boundaries. */ +export type ProviderRequestId = Branded<'ProviderRequestId'> + +/** + * Brand a provider-issued request identifier. + * @param id - the opaque provider-issued string. + * @returns the same string, branded; no validation is performed. + */ +export function ProviderRequestId(id: string): ProviderRequestId { + return id as ProviderRequestId +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 8c1c736492..4ff60c657e 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -24,6 +24,9 @@ export class HarnessError extends Error { /** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */ export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' +/** Canonical provider-neutral code for an exhausted account quota or balance. */ +export const QUOTA_EXCEEDED_CODE = 'QUOTA' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` @@ -62,6 +65,19 @@ export function isContextWindowExceededError(detail: string): boolean { || EXCEEDS_MODEL_CONTEXT.test(detail) } +/** + * Recognize provider wording that identifies an exhausted account quota rather + * than a transient request-rate limit. + * @param detail - provider error code/type/message text joined into one string. + * @returns true only for terminal quota, balance, credit, budget, or usage-limit wording. + */ +export function isQuotaExceededError(detail: string): boolean { + return /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i.test(detail) + || /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i.test(detail) + || /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted)\b/i.test(detail) + || /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail) +} + /** * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). * @param value - the caught value (`unknown` in catch clauses). diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f276aa9f92..dfebb059be 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,8 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { GenerateOptions, LlmFailure, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { ProviderRequestId } from './brand.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' @@ -21,7 +22,7 @@ export * from './types.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' -export { isLlmAdapterFailure } from './adapter-failure.ts' +export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -44,14 +45,53 @@ declare module 'cordis' { } } +/** Structured provider facts and cause accepted by {@link LlmError}. */ +export interface LlmErrorOptions extends ErrorOptions { + /** Valid HTTP status observed at the provider boundary. */ + status?: number + /** Positive finite provider-requested delay in milliseconds. */ + retryAfterMs?: number + /** Non-empty opaque provider request id. */ + requestId?: ProviderRequestId +} + /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { + /** Serializable facts retained beside this live Error. */ + readonly failure: LlmFailure + + /** + * @param message - non-empty human-readable failure summary. + * @param code - non-empty stable provider-neutral machine code. + * @param options - optional cause and validated serializable provider facts. + */ + constructor(message: string, code: string, options?: LlmErrorOptions) { + if (typeof message !== 'string' || message.length === 0) throw new Error('LlmError message must be a non-empty string') + if (typeof code !== 'string' || code.length === 0) throw new Error('LlmError code must be a non-empty string') + if (options?.status !== undefined + && (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) { + throw new Error('LlmError status must be an integer from 100 through 599') + } + if (options?.retryAfterMs !== undefined + && (!Number.isFinite(options.retryAfterMs) || options.retryAfterMs <= 0)) { + throw new Error('LlmError retryAfterMs must be a positive finite number') + } + if (options?.requestId !== undefined + && (typeof options.requestId !== 'string' || options.requestId.length === 0)) { + throw new Error('LlmError requestId must be a non-empty string') + } super(message, code, options) this.name = 'LlmError' + this.failure = Object.freeze({ + message, + code, + ...options?.status === undefined ? {} : { status: options.status }, + ...options?.retryAfterMs === undefined ? {} : { retryAfterMs: options.retryAfterMs }, + ...options?.requestId === undefined ? {} : { requestId: options.requestId }, + }) } } @@ -262,7 +302,7 @@ export class LlmService extends Service { * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - const failures: AdapterFailureScope = new WeakSet() + const failures: AdapterFailureScope = new WeakMap() const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) return bindAdapterFailureScope(stream, failures) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b8054c2046..f8412aee2b 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,21 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId } from './brand.ts' +import type { CallId, ProviderRequestId } from './brand.ts' + +/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +export interface LlmFailure { + /** Human-readable provider or transport failure. */ + readonly message: string + /** Stable provider-neutral machine-routing code. */ + readonly code: string + /** HTTP status observed at the provider boundary, when available. */ + readonly status?: number + /** Provider-requested delay in milliseconds, when valid and available. */ + readonly retryAfterMs?: number + /** Opaque provider-issued request identifier for diagnostics. */ + readonly requestId?: ProviderRequestId +} /** Plain text visible to the end user. */ export interface TextBlock { @@ -98,8 +112,8 @@ export interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } 'max-tokens': { kind: 'max-tokens' } - 'aborted': { kind: 'aborted' } - 'error': { kind: 'error'; message: string; code?: string } + 'aborted': { kind: 'aborted'; failure: LlmFailure } + 'error': { kind: 'error'; failure: LlmFailure } } /** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */ diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index 0d65b545d1..31d07c1a47 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -41,7 +41,10 @@ const chunkArb: fc.Arbitrary = indexArb.chain(index => fc.oneof( fc.constant({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }), fc.constant({ type: 'finish', reason: { kind: 'stop' } }), fc.constant({ type: 'finish', reason: { kind: 'tool-calls' } }), - fc.string().map((message): StreamChunk => ({ type: 'finish', reason: { kind: 'error', message } })), + fc.string({ minLength: 1 }).map((message): StreamChunk => ({ + type: 'finish', + reason: { kind: 'error', failure: { message, code: 'UNKNOWN' } }, + })), )) /** A stream is an arbitrary list of chunks (we do NOT force a terminal finish). */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..e491ad2dc8 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -4,9 +4,12 @@ import LlmService, { GenerateOptions, HarnessError, isContextWindowExceededError, + isQuotaExceededError, isLlmAdapterFailure, LlmAdapter, LlmError, + llmFailureOf, + ProviderRequestId, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' @@ -80,6 +83,17 @@ describe('LlmService', () => { expect(isContextWindowExceededError('context window size must be positive')).toBe(false) }) + it('distinguishes exhausted account quota from transient rate limiting', () => { + for (const detail of [ + 'insufficient_quota', + 'account balance depleted', + 'usage-limit-exceeded', + 'out of credits', + ]) expect(isQuotaExceededError(detail)).toBe(true) + expect(isQuotaExceededError('HTTP 429: rate limit reached')).toBe(false) + expect(isQuotaExceededError('quota resets in one minute')).toBe(false) + }) + it('routes stream() to the registered adapter', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -168,6 +182,151 @@ describe('LlmService', () => { expect(caught).toBe(original) expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(llmFailureOf(stream, caught)).toEqual({ + message: `${boundary} failed`, + code: 'BOUNDARY_FAILED', + }) + }) + + it('keeps structured provider facts beside a frozen third-party Error', async () => { + const original = new LlmError('provider busy', 'RATE_LIMIT', { + status: 429, + retryAfterMs: 1_500, + requestId: ProviderRequestId('req-7'), + }) + Object.freeze(original) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(llmFailureOf(stream, caught)).toEqual({ + message: 'provider busy', + code: 'RATE_LIMIT', + status: 429, + retryAfterMs: 1_500, + requestId: ProviderRequestId('req-7'), + }) + }) + + it('does not trust retry facts carried by an unknown third-party Error', async () => { + const carried = { message: 'busy', code: 'SERVER', status: 503 } + const original = Object.assign(new Error('busy'), { failure: carried }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + const facts = llmFailureOf(stream, original) + carried.status = 500 + + expect(facts).toEqual({ message: 'busy', code: 'UNKNOWN' }) + expect(Object.isFrozen(facts)).toBe(true) + expect(facts).not.toBe(carried) + }) + + it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => { + const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) + Object.defineProperty(original, 'failure', { + get() { throw new Error('SDK failure accessor must not run') }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + + expect(original.code).toBe('ECONNRESET') + expect(llmFailureOf(stream, original)).toEqual({ message: 'socket closed', code: 'UNKNOWN' }) + }) + + it('keeps an SDK Error exact when its message accessor is hostile', async () => { + const original = Object.defineProperty(new Error(), 'message', { + get() { throw new Error('SDK message accessor trap') }, + }) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' }) + }) + + it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => { + const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), { + getOwnPropertyDescriptor(target, property) { + if (property === 'failure') throw new Error('SDK descriptor trap') + return Reflect.getOwnPropertyDescriptor(target, property) + }, + }) + const throwingFacts = Object.create(null) as Record + Object.defineProperty(throwingFacts, 'message', { + get() { throw new Error('SDK fact getter trap') }, + }) + const carrying = (message: string, failure: unknown): HarnessError => Object.defineProperty( + new HarnessError(message, 'SERVER'), + 'failure', + { value: failure }, + ) + const factGetter = carrying('fact getter failed', throwingFacts) + const malformed = carrying('malformed facts', { message: 'provider busy', code: 'SERVER', requestId: 1 }) + const primitive = carrying('primitive facts', 1) + const nullFacts = carrying('null facts', null) + const mismatched = carrying('mismatched facts', { message: 'busy', code: 'RATE_LIMIT' }) + + for (const [original, expectedMessage] of [ + [propertyTrap, 'descriptor trapped'], + [factGetter, 'fact getter failed'], + [malformed, 'malformed facts'], + [primitive, 'primitive facts'], + [nullFacts, 'null facts'], + [mismatched, 'mismatched facts'], + ] as const) { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ message: expectedMessage, code: 'SERVER' }) + } + }) + + it('retains a stable code from a HarnessError without requiring LlmError facts', async () => { + const original = new HarnessError('stable adapter failure', 'ADAPTER_STABLE') + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original)) + const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] }) + + await expect((async () => { + for await (const _chunk of stream) { /* drain */ } + })()).rejects.toBe(original) + expect(llmFailureOf(stream, original)).toEqual({ + message: 'stable adapter failure', + code: 'ADAPTER_STABLE', + }) + expect(llmFailureOf(stream, 'not an Error')).toBeUndefined() + expect(llmFailureOf({ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator]() }, original)).toBeUndefined() }) it('keeps a nested adapter failure scoped to the nested model call', async () => { @@ -586,6 +745,15 @@ describe('LlmService', () => { expect(err.code).toBe('CUSTOM_CODE') }) + it('rejects non-serializable structured failure facts at construction', () => { + expect(() => new LlmError('busy', 'RATE_LIMIT', { status: 42 })).toThrow(/status/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { retryAfterMs: Number.NaN })).toThrow(/retryAfterMs/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: ProviderRequestId('') })).toThrow(/requestId/) + expect(() => new LlmError(1 as never, 'RATE_LIMIT')).toThrow(/message/) + expect(() => new LlmError('busy', 1 as never)).toThrow(/code/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: 1 as never })).toThrow(/requestId/) + }) + it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') const cause = new Error('root cause') diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 59ffc485db..846085deec 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -142,7 +142,7 @@ describe('deriveReplayScript', () => { it('keeps a finish-error chunk in the derived entry (replays naturally)', () => { const errChunks: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'finish', reason: { kind: 'error', message: 'boom', code: 'X' } }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'X' } } }, ] const events = errChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)) expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }]) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 44748add2a..b11165bd26 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -30,7 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | -| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents | +| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | | `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" | | `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | @@ -47,6 +47,8 @@ When `ctx.permission` is composed, the bridge also advertises a `permission` sel The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work. +ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events. + ## Per-session cwd `session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 84e4dcbda9..94cdd10743 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index fe6b2630c7..6d549af8e1 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -44,6 +44,7 @@ import { } from '@agentclientprotocol/sdk' import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. @@ -481,7 +482,7 @@ export function apply(ctx: Context, config: AcpConfig): void { reason: TurnEndReason, ): void => { if (reason.kind === 'error') { - inflight.reject(internalError(`turn failed: ${reason.message}`)) + inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) } else { inflight.resolve(turnEndToStopReason(reason)) } @@ -1035,6 +1036,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * identical update stream from the same event log. * * - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks + * - `llm/retry` and terminal model failure → visible discarded-attempt markers * - `user/message` → `user_message_chunk` during load replay only — so a * loaded transcript reconstructs the USER side of each turn without echoing * a live `session/prompt` back to the client @@ -1081,6 +1083,13 @@ export function streamSessionEventUpdate( } return } + case 'llm/retry': { + const text = '\n\n[Previous model attempt discarded; retrying ' + + `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: ` + + `${event.data.failure.message}]\n\n` + notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) + return + } case 'user/message': { if (!includeUserMessages) return // Replay the user's prompt so a loaded session shows both sides of each @@ -1108,7 +1117,16 @@ export function streamSessionEventUpdate( notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } }) return } - // turn/step boundaries, context/message, steering, + case 'turn/end': { + if (event.data.reason.kind !== 'error') return + const message = 'failure' in event.data.reason + ? event.data.reason.failure.message + : event.data.reason.message + const text = `\n\n[Model attempt failed; any partial output above is discarded: ${message}]\n\n` + notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) + return + } + // non-error turn/step boundaries, context/message, steering, // assistant/message — no direct ACP client update. default: return diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 1796cbf868..b8b47b80d5 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -100,7 +100,7 @@ export function errorResponse(message: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text: 'partial' }, - { type: 'finish', reason: { kind: 'error', message, code: 'PROVIDER_ERROR' } }, + { type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } }, ] } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 415e9afb33..e451099744 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -65,6 +65,33 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) + it('marks retry and terminal failure boundaries in the append-only update stream', () => { + expect(updatesFor(evt('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'backend busy', code: 'SERVER' }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n', + }, + }]) + expect(updatesFor(evt('turn/end', { + turn: 1, + reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n', + }, + }]) + }) + it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 15c2415449..e3e8d679ea 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -49,6 +49,15 @@ describe('acp bridge — turn outcomes', () => { .rejects.toThrow(/turn failed: provider boom/) }) + it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => { + harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] }) + harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) + const sessionId = await newSession(harness) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: plugin pre-step failed/) + }) + it('streams a tool call as tool_call then tool_call_update', async () => { harness = await makeBridgeHarness({ storageDir, diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 387e0d0c53..de4ccd3650 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../core/session" }, diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index eda7d00b8b..23cdb65ab1 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | `welcome` | `ready.` | Banner printed before the first prompt | | `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects. +The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. When a composed retry policy closes a failed step, the append-only transcript inserts an explicit discarded-attempt marker before later chunks; terminal request failure marks any preceding partial output discarded. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects. ```yaml - id: stdio diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index e1bffdf171..783f304823 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 6f73d948bf..3491c354a2 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -16,6 +16,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId } from '@deepseek-ai/dsh-session' import { UserInteractionError, @@ -119,6 +120,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // append order keeps `inReasoning` transitions deterministic across chunk and // boundary events. let inReasoning = false + const resetReasoning = (): void => { + if (inReasoning) output.write('\x1B[0m') + inReasoning = false + } ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { const { chunk } = event.data @@ -135,22 +140,31 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } else if (event.type === 'turn/start') { const label = target?.session === session ? 'main' : session.id output.write(`\n[${label} turn ${event.data.turn}] `) + } else if (event.type === 'llm/retry') { + resetReasoning() + output.write( + `\n [previous model attempt discarded; retry ${event.data.retry}/${event.data.maxRetries}` + + ` in ${event.data.delayMs}ms: ${event.data.failure.message}]\n `, + ) } else if (event.type === 'turn/end') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false + resetReasoning() + if (event.data.reason.kind === 'error') { + const message = 'failure' in event.data.reason + ? event.data.reason.failure.message + : event.data.reason.message + output.write(`\n [model attempt failed; any partial output above is discarded: ${message}]`) + } output.write('\n> ') } else if (event.type === 'tool/call') { const { name: toolName, arguments: args } = event.data - if (inReasoning) output.write('\x1B[0m') - inReasoning = false + resetReasoning() output.write(`\n [tool call] ${toolName}(${args})`) } else if (event.type === 'tool/result') { const { content } = event.data const text = content.filter(block => block.type === 'text').map(block => block.text).join('') output.write(`\n [tool result] ${text}\n `) } else if (event.type === 'todo/write') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false + resetReasoning() const glyph = (status: string): string => status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]' const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n') diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index a3069462ff..4babc8c117 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -290,6 +290,51 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) + it('marks failed partial output at retry and terminal failure boundaries', async () => { + const { ctx, out } = await setup() + const session = makeSession('main') + ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'partial' })) + ctx.emit('session/event', session, { + type: 'llm/retry', + seq: 1, + time: 0, + data: { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'backend busy', code: 'SERVER' }, + }, + }) + ctx.emit('session/event', session, { + type: 'turn/end', + seq: 3, + time: 0, + data: { turn: 2, reason: { kind: 'error', step: 1, message: 'loop defect' } }, + }) + ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'also partial' })) + ctx.emit('session/event', session, { + type: 'turn/end', + seq: 2, + time: 0, + data: { + turn: 1, + reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, + }, + }) + + expect(out.text()).toContain( + '\x1B[2mpartial\x1B[0m\n [previous model attempt discarded; retry 1/2 in 500ms: backend busy]', + ) + expect(out.text()).toContain( + 'also partial\n [model attempt failed; any partial output above is discarded: still busy]\n> ', + ) + expect(out.text()).toContain( + '[model attempt failed; any partial output above is discarded: loop defect]\n> ', + ) + }) + it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json index e0c578ed32..ca69d43ffd 100644 --- a/packages/ui/stdio/tsconfig.json +++ b/packages/ui/stdio/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../user-interaction" } diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 6d2c7858e4..61e6987104 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -6,7 +6,7 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear. Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index fd4f187e35..5e7c8d060c 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1c3fc1315c..39f2a5c40c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,7 +35,8 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { FileDiff, @@ -612,15 +613,38 @@ function formatCwd(cwd: string | undefined): string { return displayText(cwd) } -function sessionTokens(session: Session): { input: number; output: number } { - let input = 0 - let output = 0 - for (const event of session.events) { - if (event.type !== 'assistant/message' || event.data.usage === undefined) continue - input += event.data.usage.inputTokens - output += event.data.usage.outputTokens +interface SessionTokenTotals { + input: number + output: number + readonly byStep: Map +} + +function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = totals.byStep.get(key) + if (previous !== undefined) { + totals.input -= previous.inputTokens + totals.output -= previous.outputTokens } - return { input, output } + totals.byStep.set(key, usage) + totals.input += usage.inputTokens + totals.output += usage.outputTokens +} + +function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage) + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage) + } +} + +function sessionTokens(session: Session): SessionTokenTotals { + const totals: SessionTokenTotals = { input: 0, output: 0, byStep: new Map() } + for (const event of session.events) { + recordEventUsage(totals, event) + } + return totals } class FooterComponent implements Component { @@ -899,6 +923,14 @@ export function createTuiChat( return card } + const clearStreaming = (): void => { + if (streaming === undefined) return + const index = chat.children.indexOf(streaming) + /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */ + if (index >= 0) chat.children.splice(index, 1) + streaming = undefined + } + const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { @@ -941,15 +973,19 @@ export function createTuiChat( } break case 'assistant/message': { - if (streaming !== undefined) { - const index = chat.children.indexOf(streaming) - if (index >= 0) chat.children.splice(index, 1) - streaming = undefined - } + clearStreaming() const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) if (component.children.length > 0) chat.addChild(component) break } + case 'llm/retry': { + clearStreaming() + appendNotice( + `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, + 'warning', + ) + break + } case 'tool/call': chat.addChild(new Spacer(1)) chat.addChild(parsedTool(event)) @@ -970,9 +1006,13 @@ export function createTuiChat( todo.update(event.data.todos) break case 'turn/end': + clearStreaming() if (event.data.reason.kind === 'error') { const key = `${event.data.turn}:${event.data.reason.step}` - if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error') + const message = 'failure' in event.data.reason + ? event.data.reason.failure.message + : event.data.reason.message + if (!liveErrors.delete(key)) appendNotice(message, 'error') } else if (event.data.reason.kind === 'aborted') { appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning') } else if (event.data.reason.kind === 'max-tokens') { @@ -1245,10 +1285,7 @@ export function createTuiChat( const disposeSessionEvents = ctx.on('session/event', (session, event) => { if (session !== agent.session) return - if (event.type === 'assistant/message' && event.data.usage !== undefined) { - tokens.input += event.data.usage.inputTokens - tokens.output += event.data.usage.outputTokens - } + recordEventUsage(tokens, event) if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 9994833308..96d0570921 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -120,10 +120,11 @@ export function appendAssistant( session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }, + position: { turn: number; step: number } = { turn: 1, step: 0 }, ): void { session.append('assistant/message', { - turn: 1, - step: 0, + turn: position.turn, + step: position.step, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content, ...usage === undefined ? {} : { usage }, diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt new file mode 100644 index 0000000000..f257b1e9f6 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -0,0 +1,48 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=15 bufferRow=15 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Start then cancel. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 1000ms: temporary transport failure " + style 1-67 fg=yellow +12| +13| " cancelled during retry delay " + style 1-28 fg=yellow +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| " " + style 1-1 inverse +16| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +18-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt new file mode 100644 index 0000000000..b1cf16d42a --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -0,0 +1,45 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=13 bufferRow=13 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Let the bounded policy exhaust. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " provider still unavailable " + style 1-26 fg=red +12| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +13| " " + style 1-1 inverse +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +16-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt new file mode 100644 index 0000000000..7d692e4365 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -0,0 +1,49 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=16 bufferRow=16 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Recover this request. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 500ms: provider rate limit " + style 1-58 fg=yellow +12| +13| " Assistant " + style 1-9 fg=bright-magenta bold +14| " Recovered on the next bounded attempt. " +15| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +16| " " + style 1-1 inverse +17| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt new file mode 100644 index 0000000000..5cd27db26b --- /dev/null +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -0,0 +1,45 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=13 bufferRow=13 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Recover this request. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Retrying model request (1/2) in 500ms: provider rate limit " + style 1-58 fg=yellow +12| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +13| " " + style 1-1 inverse +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +16-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 609574e758..b8afc452cb 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-llm-retry' import type { Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools' @@ -24,6 +25,10 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ 'conversation-streaming', + 'retry-scheduled', + 'retry-recovered', + 'retry-cancelled', + 'retry-exhausted', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -222,6 +227,82 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins failed-stream retraction, scheduled retry, and eventual success', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Recover this request.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'discarded partial output' }, + }) + harness.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'provider rate limit', code: 'RATE_LIMIT', status: 429 }, + }) + }) + await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + harness.session.append('assistant/message', { + turn: 1, + step: 2, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }], + }, { surfaceOp: 'append' }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins cancellation during a scheduled retry delay', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Start then cancel.') + harness.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 1_000, + failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, + }) + harness.session.append('turn/end', { + turn: 1, + reason: { kind: 'aborted', reason: 'cancelled during retry delay' }, + }) + }) + await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins terminal exhaustion after retracting a failed partial stream', async () => { + const harness = await setupSnapshot() + await renderAfter(harness, () => { + appendUser(harness.session, 'Let the bounded policy exhaust.') + harness.session.append('assistant/chunk', { + turn: 1, + step: 3, + chunk: { type: 'text-delta', index: 0, text: 'discarded terminal partial output' }, + }) + harness.session.append('turn/end', { + turn: 1, + reason: { + kind: 'error', + step: 3, + failure: { message: 'provider still unavailable', code: 'SERVER', status: 503 }, + }, + }) + }) + await checkpoint('retry-exhausted', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..813b5dd677 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -7,6 +7,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type {} from '@deepseek-ai/dsh-llm-retry' import { createTuiChat, mountTui, @@ -244,7 +245,12 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('live thought') result.terminal.send('\x12') await tick() - appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) + appendAssistant( + result.session, + [{ type: 'text', text: 'final live answer' }], + { inputTokens: 500, outputTokens: 8 }, + { turn: 2, step: 0 }, + ) await tick() expect(result.terminal.output).toContain('Working') @@ -276,6 +282,68 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20) }) + it('counts failed and recovered request usage once per step', async () => { + const result = await setup() + result.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 2 } }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'temporary', code: 'SERVER' }, + }) + result.session.append('assistant/chunk', { + turn: 1, + step: 2, + chunk: { type: 'usage', usage: { inputTokens: 7, outputTokens: 3 } }, + }) + appendAssistant( + result.session, + [{ type: 'text', text: 'recovered' }], + { inputTokens: 7, outputTokens: 3 }, + { turn: 1, step: 2 }, + ) + await tick() + + expect(result.terminal.output).toContain('↑17 ↓5') + await dispose(result) + }) + + it('retracts a failed live stream and renders its durable retry status', async () => { + const result = await setup() + result.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 500, + failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 }, + }) + result.session.append('llm/retry', { + turn: 1, + step: 2, + retry: 2, + maxRetries: 2, + delayMs: 1_000, + failure: { message: 'failed before chunks', code: 'SERVER', status: 503 }, + }) + await tick() + + expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited') + expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks') + await dispose(result) + }) + it('renders the ANSI palette and every markdown/content style', async () => { const result = await setup({ config: { color: true }, @@ -453,10 +521,15 @@ describe('pi-tui chat lifecycle and transcript', () => { events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } }) events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } }) + events.session.append('turn/end', { + turn: 9, + reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, + }) events.ctx.emit('agent/disposed', events.agent) await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') + expect(events.terminal.output).toContain('structured provider failure') expect(events.terminal.output).toContain('stopped') expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('Turn rejected') diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3a09f80ad8..0c9e89e9e5 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../core/tools" }, diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index b5923a3a73..cb6f0aa558 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -9,13 +9,15 @@ It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds ## Surface ```ts -import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' +import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' ``` | Export | Role | |---|---| | `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. | | `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. | +| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. | +| `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. | | `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). | | `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. | @@ -44,6 +46,8 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired. +For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. + ## What does NOT get a timeout Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md). @@ -61,3 +65,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Notification only** — a deadline cannot stop work that ignores its signal; every capability still needs its own socket/process/task termination path. - **`timeoutMs <= 0` is internal vocabulary** — it disables the local timer only after an owning backend has resolved policy, never as a public model/plugin knob. - **The first abort reason wins classification** — when an upstream cancellation beats the local timer, this layer cannot later report that its own timeout would also have elapsed. +- **An idle watchdog is not a total deadline** — it rearms per outstanding iterator demand and deliberately excludes consumer think time. diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index 47c5d87c84..a9bd47eb08 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -21,6 +21,15 @@ export class TimeoutReason extends Error { } } +/** Largest delay Node schedules without clamping it to one millisecond. */ +export const MAX_TIMER_DELAY_MS = 2_147_483_647 + +function assertTimerDelay(timeoutMs: number, name: string): void { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** * Validate a caller's optional timeout hint, use the backend default, then cap * it. Supplied values must be positive and finite; zero is not a public @@ -53,6 +62,20 @@ export interface Deadline { [Symbol.dispose](): void } +/** Rearmable timeout around one outstanding async-iterator demand. */ +export interface IdleWatchdog { + /** Stable signal aborted by upstream cancellation or this watchdog's timeout. */ + readonly signal: AbortSignal + /** + * Await one iterator demand while the idle timer is armed. + * @param iterator - iterator whose next value represents provider progress. + * @returns the iterator's next result. + */ + next(iterator: AsyncIterator): Promise> + /** Clear an armed timer; safe to call once at the owning stream's exit. */ + [Symbol.dispose](): void +} + /** * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is * the internal no-timer sentinel; the returned disposer clears an armed timer. @@ -74,6 +97,8 @@ export function deadline( return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} } } + assertTimerDelay(timeoutMs, 'deadline timeoutMs') + const timer = new AbortController() const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs) return { @@ -85,6 +110,57 @@ export function deadline( } } +/** + * Create a rearmable idle watchdog for an async iterator. The timer exists only + * while {@link IdleWatchdog.next} is outstanding, so consumer think time does + * not count as provider idle time. The returned signal is stable for the whole + * call and only notifies; the iterator must observe it to terminate its work. + * + * @param upstream - caller cancellation fused into the stable signal. + * @param timeoutMs - positive finite idle interval in milliseconds. + * @param code - capability-owned code carried by the timeout reason. + * @returns a stable signal, guarded next operation, and timer disposer. + */ +export function idleWatchdog( + upstream: AbortSignal | undefined, + timeoutMs: number, + code: string, +): IdleWatchdog { + assertTimerDelay(timeoutMs, 'idleWatchdog timeoutMs') + const timeout = new AbortController() + const signal = upstream === undefined + ? timeout.signal + : AbortSignal.any([upstream, timeout.signal]) + let timer: ReturnType | undefined + let outstanding = false + let disposed = false + + return { + signal, + async next(iterator: AsyncIterator): Promise> { + if (disposed) throw new Error('idleWatchdog is disposed') + if (outstanding) throw new Error('idleWatchdog next is already outstanding') + outstanding = true + timer = setTimeout(() => { + timeout.abort(new TimeoutReason(code, timeoutMs)) + }, timeoutMs) + try { + return await iterator.next() + } finally { + clearTimeout(timer) + timer = undefined + outstanding = false + } + }, + [Symbol.dispose](): void { + if (disposed) return + disposed = true + if (timer !== undefined) clearTimeout(timer) + timer = undefined + }, + } +} + /** * Recover a timeout reason from a reason-bearing object. Supplying `code` * distinguishes this deadline from a nested upstream deadline; a foreign code diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index dd4da3adde..11779c915f 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout' +import { + clampTimeout, + deadline, + idleWatchdog, + MAX_TIMER_DELAY_MS, + timeoutOf, + TimeoutReason, +} from '@deepseek-ai/dsh-timeout' describe('TimeoutReason', () => { it('is an Error carrying the code and elapsed ms', () => { @@ -67,6 +74,13 @@ describe('deadline — timeout arm', () => { expect(d.signal.aborted).toBe(false) expect(timeoutOf(d.signal)).toBeUndefined() }) + + it('rejects delays that Node would clamp to one millisecond', () => { + expect(() => deadline(undefined, MAX_TIMER_DELAY_MS + 1, 'BASH_TIMEOUT')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + expect(() => deadline(undefined, Number.POSITIVE_INFINITY, 'BASH_TIMEOUT')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + }) }) describe('deadline — fuse with upstream', () => { @@ -182,3 +196,74 @@ describe('deadline — nested deadlines', () => { expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped }) }) + +describe('idleWatchdog', () => { + afterEach(() => { vi.useRealTimers() }) + + it('arms only while next is outstanding and rearms the same signal for later demand', async () => { + vi.useFakeTimers() + const first = Promise.withResolvers>() + const second = Promise.withResolvers>() + const iterator: AsyncIterator = { + next: vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise), + } + using watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + const stableSignal = watchdog.signal + + const firstNext = watchdog.next(iterator) + await vi.advanceTimersByTimeAsync(99) + expect(stableSignal.aborted).toBe(false) + first.resolve({ done: false, value: 1 }) + await expect(firstNext).resolves.toEqual({ done: false, value: 1 }) + + await vi.advanceTimersByTimeAsync(10_000) + expect(stableSignal.aborted).toBe(false) + expect(watchdog.signal).toBe(stableSignal) + + const secondNext = watchdog.next(iterator) + await vi.advanceTimersByTimeAsync(100) + expect(timeoutOf(stableSignal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 }) + second.reject(stableSignal.reason) + await expect(secondNext).rejects.toBe(stableSignal.reason) + }) + + it('keeps an earlier upstream abort distinct from its own timeout', async () => { + vi.useFakeTimers() + const upstream = new AbortController() + using watchdog = idleWatchdog(upstream.signal, 100, 'LLM_STREAM_IDLE_TIMEOUT') + upstream.abort('caller cancelled') + expect(watchdog.signal.aborted).toBe(true) + expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toBeUndefined() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.reason).toBe('caller cancelled') + }) + + it('clears an outstanding arm on disposal', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers>() + const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + void watchdog.next({ next: () => pending.promise }) + watchdog[Symbol.dispose]() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.aborted).toBe(false) + pending.resolve({ done: true, value: undefined }) + await expect(watchdog.next({ next: () => Promise.resolve({ done: true, value: undefined }) })) + .rejects.toThrow(/disposed/) + watchdog[Symbol.dispose]() + }) + + it('rejects invalid bounds and concurrent iterator demand', async () => { + expect(() => idleWatchdog(undefined, 0, 'IDLE')).toThrow(/positive finite/) + expect(() => idleWatchdog(undefined, Number.NaN, 'IDLE')).toThrow(/positive finite/) + expect(() => idleWatchdog(undefined, MAX_TIMER_DELAY_MS + 1, 'IDLE')) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + const pending = Promise.withResolvers>() + using watchdog = idleWatchdog(undefined, 100, 'IDLE') + const iterator = { next: () => pending.promise } + void watchdog.next(iterator) + await expect(watchdog.next(iterator)).rejects.toThrow(/already outstanding/) + pending.resolve({ done: true, value: undefined }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dba3435f60..96e66247c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -716,6 +719,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1132,6 +1138,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1151,10 +1160,56 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../llm-deepseek + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/llm/llm-retry: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/llm/token-meter: dependencies: schemastery: @@ -1948,6 +2003,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../permission @@ -2080,6 +2138,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2132,6 +2193,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2528,6 +2592,9 @@ importers: '@deepseek-ai/dsh-llm-pi-ai': specifier: workspace:^ version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../packages/llm/llm-retry '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2d3560ec5c..c9075f26a3 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..84ce9991dc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -34,6 +34,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', Message: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..ccd573dd9f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -6,6 +6,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "AssistantProvenance", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "LlmFailure", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, @@ -32,6 +33,7 @@ { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmFailure", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index c056aa43db..53c3bfa2d0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -36,6 +36,7 @@ { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, { "path": "./packages/bash/bash" }, diff --git a/tsconfig.json b/tsconfig.json index 52337c6a71..d69c831a9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,7 @@ { "path": "./packages/ui/tool-ask-user" }, { "path": "./packages/context/workspace-context" }, { "path": "./packages/core/agent-loop" }, + { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, { "path": "./packages/bash/bash" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..218ce99dba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L312) ### agent/post-step @@ -226,12 +226,13 @@ Replace the frozen call configuration. Model-visible content must use logged cha * @param turn - the open turn number. * @param step - the failed step number. * @param error - the original model-request failure. - * @param retryAttempt - zero-based number of prior recovery retries. + * @param failure - serializable facts normalized at the final adapter boundary. + * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise ``` Recover a model-request failure after its failed step has closed. `retry` opens a new numbered step; `fail` preserves the original request error. Call `next()` to delegate to the next recovery listener or the default. @@ -240,10 +241,11 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `turn` — the open turn number. - `step` — the failed step number. - `error` — the original model-request failure. -- `retryAttempt` — zero-based number of prior recovery retries. +- `failure` — serializable facts normalized at the final adapter boundary. +- `priorFailures` — immutable failures that already authorized another request in this consecutive sequence. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L279) ### agent/session-prefix @@ -373,7 +375,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L289) ### agent/turn-stop @@ -397,7 +399,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L299) ## agent-loop/* @@ -544,7 +546,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t - `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L43) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L44) ## session/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index e8e4d53a16..548e462b43 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L137) ### ctx.llm.registerAdapter(providers, adapter) @@ -29,7 +29,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152) ### ctx.llm.listProviders() @@ -45,7 +45,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L183) ### ctx.llm.listModels(provider) @@ -65,7 +65,7 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L153) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L193) ### ctx.llm.stream(options) @@ -91,4 +91,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L304) From 3c2e07a92d48c712f707603e78bab4954e03a33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:38:28 +0800 Subject: [PATCH 052/132] fix(goal): make publication reentrancy-safe --- ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 5 +- ...9-persisted-same-session-goal-domain.zh.md | 5 +- docs/cordis-catalog/services.md | 2 +- docs/glossary.md | 2 +- packages/goal/goal/README.md | 3 +- packages/goal/goal/src/index.ts | 47 +++++++--- packages/goal/goal/tests/goal.spec.ts | 92 +++++++++++++++++++ website/zh-CN/api/harness/goals.md | 24 ++--- 9 files changed, 152 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index 86c1ae0715..d398150b73 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: 3404e24c3cc5880f0eeddbdbf35e01ad32063b74 -2026-07-19-persisted-same-session-goal-domain.zh.md: fb12aad5d7c035a2befc30ffd650dcd336233104 +2026-07-19-persisted-same-session-goal-domain.md: 1484a919851c18ce978c93c068f6096bbf3b733f +2026-07-19-persisted-same-session-goal-domain.zh.md: 8bcedc6cab6b9219a33a655e8fcbc53762b7413f diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 3404e24c3c..1484a91985 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -22,7 +22,7 @@ Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `c The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. -When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; the durable log remains authoritative after restart. +When `Agent.inject()` defers a mutation inside an active tool batch, the service overlays the accepted payload in process memory so a later mutation can use its new revision. Reconciliation removes only an exact matching payload when the FIFO append becomes visible; reentrant append observers project each mutation exactly once. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart. ### Lifecycle and live activation @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered @@ -59,4 +59,5 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - This domain records state but does not schedule goal rounds, cancel active turns, or classify abnormal stops. - The actor that records `complete` or `blocked` is authoritative; an independent evaluator or completion certificate is deferred to a policy consumer. - There is one current goal per session; parallel objective graphs and cross-session goal storage are absent. +- Plugins share one trusted process boundary. Direct session writers can counterfeit goal records; strict replay detects inconsistency and fails goal access at the offending record, but does not isolate plugins or repair the log. - `GOAL_CHANGE_VERSION` has no pre-release compatibility promise or migration path. diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index fb12aad5d7..8bcedc6cab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -22,7 +22,7 @@ Status: implemented 回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 -当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重启后仍以持久日志为准。 +当 `Agent.inject()` 在活跃工具批次中延迟变更时,服务会在进程内叠加已接受的载荷,使后续变更可以使用新的修订号。FIFO 追加可见后,协调过程只移除完全匹配的载荷;重入的追加观察器对每次变更只投影一次。增量回放会在每个有效事件后推进游标,并停留在首个损坏事件处,因此后续读取会报告同一个持久故障。重启后仍以持久日志为准。 ### 生命周期与实时激活态 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 @@ -59,4 +59,5 @@ Status: implemented - 本领域记录状态,但不调度目标回合、不取消活跃轮次,也不分类异常停止。 - 记录 `complete` 或 `blocked` 的参与者具有最终权威;独立评估器或完成证书延期到策略消费者中实现。 - 每个会话只有一个当前目标;不存在并行目标图和跨会话目标存储。 +- 插件共享同一个受信任的进程边界。直接写入会话的插件可以伪造目标记录;严格回放会检测不一致并在违规记录处使目标访问失败,但不会隔离插件或修复日志。 - `GOAL_CHANGE_VERSION` 在首次发布前不承诺兼容性,也不提供迁移路径。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 934086dd97..7d33e49c13 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -577,7 +577,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:97`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:104`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/glossary.md b/docs/glossary.md index 480ef461ff..0166f01f63 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,4 +26,4 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains one or more steps. -- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round). Round counters belong to that policy and do not count every turn in a session. diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 4247ccb5e6..0e0807f684 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -21,7 +21,7 @@ At most one goal is current. Creation produces an active revision-one goal and a Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. -Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. +Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained. Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. Session resume and fork therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. @@ -51,3 +51,4 @@ Append-only within an epoch: each mutation follows the reusable request prefix a - **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas. - **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer. - **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear. +- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation. diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 726ceb6c53..c8583894ef 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -64,12 +64,19 @@ export interface ResolvedConfig { defaultMaxGoalRounds: number } +/** One accepted mutation waiting to enter or be observed in the session log. */ +interface PendingGoalChange { + readonly change: GoalChangeMeta + readonly activation: GoalActivation + applied: boolean +} + /** Process-local cache plus mutations waiting in the active tool-batch FIFO. */ interface GoalCache { readonly state: GoalFoldState activation: GoalActivation observedSeq: number - readonly pending: GoalChangeMeta[] + readonly pending: PendingGoalChange[] } /** Validate a caller-visible positive safe-integer round cap. */ @@ -358,15 +365,21 @@ export class GoalService extends Service { const change = decodeGoalEvent(event) if (change !== undefined) { const pending = cache.pending[0] - if (pending !== undefined && sameChange(pending, change)) { + if (pending !== undefined && sameChange(pending.change, change)) { + if (!pending.applied) { + applyGoalChange(cache.state, change) + cache.activation = pending.activation + pending.applied = true + } cache.pending.shift() + cache.observedSeq += 1 continue } } } applyGoalEvent(cache.state, event) + cache.observedSeq += 1 } - cache.observedSeq = session.seq } /** Build a new revision with one replacement phase. */ @@ -464,14 +477,26 @@ export class GoalService extends Service { const meta = snapshotJsonValue(change) as JsonValue | undefined /* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */ if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable') - agent.inject(renderGoalChange(change), { - source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, - envelope: 'raw', - meta, - }) - cache.pending.push(change) - applyGoalChange(cache.state, change) - cache.activation = activation + const pending: PendingGoalChange = { change, activation, applied: false } + cache.pending.push(pending) + try { + agent.inject(renderGoalChange(change), { + source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 }, + envelope: 'raw', + meta, + }) + } catch (error: unknown) { + const index = cache.pending.indexOf(pending) + /* v8 ignore next -- a committed goal append cannot reject after its contained observers run */ + if (index < 0) throw new Error('goal injection failed after its pending mutation was reconciled', { cause: error }) + cache.pending.splice(index, 1) + throw error + } + if (!pending.applied) { + applyGoalChange(cache.state, change) + cache.activation = activation + pending.applied = true + } this.sync(agent.session, cache) const goal = this.view(cache) const notification: GoalChanged = { diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c8aa5bc3e3..4dcae8938a 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -235,6 +235,25 @@ describe('GoalService creation and replay', () => { expect(() => foldGoal(session.events)).not.toThrow() }) + it('removes the service and its session-start listener with the providing fiber', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(GoalService) + const first = ctx.goals + const stub = stubAgent('goal-hmr') + ctx.agents.register(stub.agent) + const goal = first.create(stub.agent, { objective: 'survive service reload' }) + + await fiber.dispose() + expect(ctx.get('goals')).toBeUndefined() + agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') + expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) + + await ctx.plugin(GoalService) + expect(ctx.goals).not.toBe(first) + expect(ctx.goals.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'disarmed' }) + }) + it('requires the exact live registry instance for reads and mutations', async () => { const { ctx, agent } = await harness() const impostor = { ...agent, session: new Session(agent.id) } @@ -404,6 +423,46 @@ describe('GoalService mutations', () => { expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) + it('publishes a mutation consistently to a reentrant session observer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) + ctx.agents.register(stub.agent) + let observed: ReturnType + ctx.on('session/event', (session, event) => { + if (session === stub.session && event.type === 'context/message') observed = ctx.goals.get(stub.agent) + }) + + const created = ctx.goals.create(stub.agent, { objective: 'publish once' }) + + expect(observed).toEqual(created) + expect(ctx.goals.get(stub.agent)).toEqual(created) + expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) + }) + + it('rolls back a pending mutation when injection rejects before append', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(GoalService) + const stub = stubAgent('goal-rejected-injection') + const append = stub.agent.inject.bind(stub.agent) + let reject = true + stub.agent.inject = (content, options) => { + if (reject) throw new Error('injection rejected') + append(content, options) + } + ctx.agents.register(stub.agent) + + expect(() => ctx.goals.create(stub.agent, { objective: 'first attempt' })).toThrow('injection rejected') + reject = false + expect(ctx.goals.create(stub.agent, { objective: 'second attempt' })).toMatchObject({ + objective: 'second attempt', + revision: 1, + }) + }) + it('rejects deferred goal mutations that enter the log out of FIFO order', async () => { const test = await harness() test.setDeferred(true) @@ -447,6 +506,39 @@ describe('GoalService mutations', () => { activation: 'disarmed', }) }) + + it('reports the same corrupt unseen event after committing its valid prefix', async () => { + const { ctx, agent, session } = await harness() + expect(ctx.goals.get(agent)).toBeUndefined() + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-valid-prefix'), + revision: 1, + objective: 'valid prefix', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 12, + updatedAt: 12, + } + appendInjection(session, renderGoalChange(change), { + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 }, + envelope: 'raw', + meta: change as never, + }) + appendInjection(session, [{ type: 'text', text: 'corrupt' }], { + source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 }, + envelope: 'raw', + meta: { ...change, operation: 'edit', extra: true } as never, + }) + + expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + }) }) describe('goal replay validation', () => { diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 146cb04193..8789074e36 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,7 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L104) ### ctx.goals.resolveCreate(request) @@ -25,7 +25,7 @@ Materialize deployment defaults and validate one create request. **Returns** detached, fully resolved create specification. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L122) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L129) ### ctx.goals.get(agent) @@ -45,7 +45,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L142) ### ctx.goals.create(agent, request) @@ -67,7 +67,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L149) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L156) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +90,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L174) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L181) ### ctx.goals.pause(agent, ref) @@ -111,7 +111,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L195) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L202) ### ctx.goals.resume(agent, ref) @@ -133,7 +133,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L206) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L213) ### ctx.goals.complete(agent, ref) @@ -154,7 +154,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L231) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L238) ### ctx.goals.block(agent, ref) @@ -175,7 +175,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L255) ### ctx.goals.markUsageLimited(agent, ref) @@ -196,7 +196,7 @@ Mark an active goal stopped by an external usage limit. **Returns** the usage-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L258) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L265) ### ctx.goals.markBudgetLimited(agent, ref) @@ -217,7 +217,7 @@ Mark an active goal stopped at its configured round cap. **Returns** the budget-limited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) ### ctx.goals.clear(agent, ref) @@ -238,4 +238,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L295) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L302) From 89f9900be1ad5d5b02c376c9dd61eaf9bc143e01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:47:05 +0800 Subject: [PATCH 053/132] fix(goal): preserve interactive human turns --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 7 +- .../2026-07-19-model-facing-goal-tools.zh.md | 7 +- docs/cordis-catalog/events.md | 30 ++++----- docs/event-producer-consumer.md | 30 ++++----- docs/tool-catalog.md | 6 +- .../fixtures/goal/tool-goal/scripted-llm.ts | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 5 +- packages/goal/tool-goal/README.md | 5 +- packages/goal/tool-goal/src/authority.ts | 6 +- packages/goal/tool-goal/src/index.ts | 47 +++++++------ .../goal/tool-goal/tests/tool-goal.e2e.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 67 +++++++++++++++++-- website/zh-CN/api/harness/events.md | 30 ++++----- 15 files changed, 163 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index af951c46ff..f857c5360e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 3d9a33566d3ae27f803cbe56b7464bd1da17c1eb -2026-07-19-model-facing-goal-tools.zh.md: 45ca2dfdc3523a3598add845224cda6f58d2c738 +2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 +2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 3d9a33566d..e37eaabecf 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. -A successful update that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request after pause, block, or completion. A later successful resume in the same turn removes that contribution. +An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. @@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact live-agent and driver checks, root-versus-child authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, compare-and-set and argument failures, exact goal-round completion, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. ## Alternatives considered @@ -61,5 +61,6 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr - Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred. - These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors. +- Goal-round authority is dormant unless a separately mounted continuation driver admits goal-sourced user turns; this tool package never manufactures that authority itself. - Human slash-command discovery and rendering are deferred to the command-surface layer. - A scope can hide tool registrations while leaving the independently registered prompt section visible unless the deployment scopes both together. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index 45ca2dfdc3..c0d289271d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,13 +22,13 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 -成功更新后若目标处于停止状态,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免在暂停、阻塞或完成后再发起一次不必要的模型请求。同一轮次中后续成功的恢复会移除该贡献。 +自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确实时智能体与驱动检查、根与子智能体权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、比较并交换与参数失败、准确目标回合的完成、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 ## 考虑过的替代方案 @@ -61,5 +61,6 @@ Status: implemented - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。 - 这些工具会改变目标状态,但不调度目标回合、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。 +- 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则目标回合权限路径处于休眠状态;本工具包本身不会制造这种权限。 - 面向人类的斜杠命令发现与渲染延期到命令表面层。 - 若部署没有同时设定两个注册项的作用域,某个作用域可能隐藏工具注册,却保留独立注册的提示词段。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c90eacde5e..cc27559e84 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d48349cdf0..b7a7f6bb12 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:178`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2bd4f5d3de..3b63dceb1c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -410,7 +410,7 @@ Create one persisted same-session completion goal when the current direct human }, "max_goal_rounds": { "type": "number", - "description": "Optional positive safe-integer cap; omission uses the goal-domain deployment default." + "description": "Optional positive safe-integer limit on automatic continuation rounds." } }, "required": [ @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, durable phase, admitted round count, cap, and live process-local activation. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human turn. complete and blocked additionally accept the exact admitted goal round. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. ```json { diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts index 7b635ba0bb..310737050d 100644 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts @@ -1,4 +1,4 @@ -/** Deterministic adapter that creates, reads, then pauses one goal. */ +/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ import type { Context } from 'cordis' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' @@ -75,7 +75,7 @@ class GoalScriptAdapter extends LlmAdapter { if (goal === undefined) throw new Error('scripted goal state missing') return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) } - if (prompt.text === 'pause') return textReply('UNEXPECTED CONTINUATION AFTER PAUSE') + if (prompt.text === 'pause') return textReply('GOAL PAUSED') return textReply('UNEXPECTED PROMPT') } } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c2b46f210d..6d2df3d957 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue a message; starts a turn when idle. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..e9ee359da8 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -25,7 +25,10 @@ export interface AgentOptions { model?: string } -/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ +/** + * Message options. An omitted source attests direct human input as `{ kind: 'user' }` + * and may authorize policy consumers, so non-human producers must label their content. + */ export interface SendOptions { source?: MessageSource } diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 42f62b7ce9..1ac03ac04a 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -10,12 +10,14 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. -A successful mutation that leaves the goal stopped contributes the existing terminal `agent/turn-stop` decision for that physical turn. A later same-turn resume clears the contribution. This avoids an extra model request after pause, block, or completion without changing ordinary loop continuation. +An autonomous goal round that successfully reports `complete` or `blocked` contributes the existing terminal `agent/turn-stop` decision for that physical turn. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does. +`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. + Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. ## Config @@ -70,4 +72,5 @@ Schemas are prefix-stable while their definitions and visibility are unchanged. - **Semantic intent remains model judgment** — execution can prove direct human provenance, not whether a request is substantial enough to merit a goal. - **Same-condition blocking remains model judgment** — the runtime enforces distinct admitted-round count, not semantic equivalence of obstacles; an independent evaluator is deferred. - **No scheduling or UI commands** — these tools mutate state only; the same-session driver and human command surfaces are separate stack layers. +- **Goal-round authority requires a driver** — the autonomous `complete`/`blocked` path is dormant unless a continuation driver admits goal-sourced user turns; mounting this tool package alone does not create them. - **Prompt registration is independent of filtering** — a scope may hide the tools while retaining their guidance unless the deployment scopes both registrations together. diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index a2238aab9c..41fe713dc6 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -62,7 +62,11 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE return { agent, ...openTurn(agent) } } -/** Whether an accepted human message appears in the current root-agent turn. */ +/** + * Whether host-attested human input appears in the current root-agent turn. + * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human + * producers must supply their own source rather than inheriting this authority. + */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false return execution.events.some(event => diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 29068a4afa..5c395898b7 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -50,8 +50,8 @@ const CREATE_DESCRIPTION = + 'trivial single-turn work. Execution rejects non-human and subagent authority.' const GET_DESCRIPTION = - 'Read the current same-session goal, including its exact id/revision, durable phase, admitted ' - + 'round count, cap, and live process-local activation. Call this before updating a goal.' + 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' + + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -107,13 +107,14 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } } } -/** Remember whether one successful mutation makes this turn terminal. */ +/** Remember whether one autonomous terminal report should stop this turn. */ function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, goal: GoalView, + autonomousTerminal: boolean, ): void { - if (goal.phase === 'active' && goal.activation === 'armed') { + if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { terminalTurns.delete(execution.agent) return } @@ -123,6 +124,8 @@ function observeMutation( /** Register the three Codex-shaped goal tools and their shared policy section. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) + // A stale entry cannot match a later loop turn because turn numbers increase + // monotonically within the agent's fixed session. const terminalTurns = new WeakMap() ctx.on('agent/turn-stop', (agent, turn) => { if (terminalTurns.get(agent) !== turn) return undefined @@ -160,7 +163,7 @@ export function apply(ctx: Context, config: Config): void { }, max_goal_rounds: { type: 'number', - description: 'Optional positive safe-integer cap; omission uses the goal-domain deployment default.', + description: 'Optional positive safe-integer limit on automatic continuation rounds.', }, }, execute(args, exec) { @@ -170,7 +173,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -179,8 +182,8 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'update_goal', description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' - + 'top-level human turn. complete and blocked additionally accept the exact admitted goal ' - + 'round. blocked is rejected before the configured minimum round count; the model remains ' + + 'top-level human request. During an automatic continuation of the current goal, complete ' + + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' + 'responsible for judging that the same condition persisted across those rounds.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, @@ -204,27 +207,33 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), }]) } + if (args.action === 'pause' || args.action === 'resume') { + requireDirectHuman(ctx, execution) + if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + throw new HarnessError( + 'objective and max_goal_rounds are valid only with action edit', + 'GOAL_TOOL_INVALID_UPDATE', + ) + } + const goal = args.action === 'pause' + ? ctx.goals.pause(execution.agent, ref) + : ctx.goals.resume(execution.agent, ref) + observeMutation(terminalTurns, execution, goal, false) + return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) + } + const authority = completionAuthority(ctx, execution) if (args.objective !== undefined || args.max_goal_rounds !== undefined) { throw new HarnessError( 'objective and max_goal_rounds are valid only with action edit', 'GOAL_TOOL_INVALID_UPDATE', ) } - if (args.action === 'pause' || args.action === 'resume') { - requireDirectHuman(ctx, execution) - const goal = args.action === 'pause' - ? ctx.goals.pause(execution.agent, ref) - : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) - return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) - } - const authority = completionAuthority(ctx, execution) if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -236,7 +245,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal) + observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts index 34f7bf243c..b19a4ee851 100644 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ b/packages/goal/tool-goal/tests/tool-goal.e2e.ts @@ -101,7 +101,7 @@ describe('goal tools through a real Loader, app, and stdio process', () => { expect(stdout).toContain('goal-tools e2e ready.') expect(stdout).toContain('GOAL CREATED') expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).not.toContain('UNEXPECTED CONTINUATION AFTER PAUSE') + expect(stdout).toContain('GOAL PAUSED') const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2532b16a15..3950bf1e63 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -7,7 +7,7 @@ import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -20,8 +20,8 @@ interface StubAgent { } /** Build one registry-compatible live agent whose injections append in place. */ -function stubAgent(rawId: string): StubAgent { - const session = new Session(SessionId(rawId)) +function stubAgent(rawId: string, supplied?: Session): StubAgent { + const session = supplied ?? new Session(SessionId(rawId)) let status: AgentStatus = 'running' const agent: Agent = { id: session.id, @@ -219,6 +219,42 @@ describe('goal tool execution authority', () => { expect(childResult.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) + it('rejects stale agent objects and agents outside running status through the executor', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const stale = { ...root.agent } + const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) + expect(staleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + + root.setStatus('idle') + const idleResult = await execute(ctx, 'get_goal', {}, root.agent) + expect(idleResult.error?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') + }) + + it('treats a fork resumed as a runtime root as direct-human authority', async () => { + const { ctx, root } = await harness() + const originalTurn = openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) + closeTurn(root, originalTurn) + const forkId = SessionId('goal-tool-resumed-fork') + const forkSession = new Session(forkId, root.session.events, { + version: SESSION_FORMAT_VERSION, + id: forkId, + createdAt: Date.now(), + parentSession: root.session.id, + seedLength: root.session.seq, + }) + const fork = stubAgent(forkId, forkSession) + ctx.agents.register(fork.agent) + expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' }) + + openTurn(fork, { kind: 'user' }, '继续这个目标') + const resumed = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'resume', + }, fork.agent) + expect(resultGoal(resumed)).toMatchObject({ id: created.id, revision: 2, phase: 'active' }) + }) + it('rejects calls before a turn and after its end boundary', async () => { const { ctx, root } = await harness() const before = await execute(ctx, 'get_goal', {}, root.agent) @@ -237,6 +273,10 @@ describe('goal tool execution authority', () => { goal_id: 'goal-missing', revision: 1, action: 'complete', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + const malformed = await execute(ctx, 'update_goal', { + goal_id: 'goal-missing', revision: 1, action: 'pause', objective: 'probe', + }, root.agent) + expect(malformed.error?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) it('accepts direct human steering in a goal-sourced root turn', async () => { @@ -290,16 +330,29 @@ describe('goal tool state transitions', () => { expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() }) - it('stops the current turn after a successful stopped-state mutation', async () => { + it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() - openTurn(root, { kind: 'user' }) + const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toEqual({ action: 'stop' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined() + const resumed = resultGoal(await execute(ctx, 'update_goal', { + goal_id: created.id, revision: 2, action: 'resume', + }, root.agent)) + closeTurn(root, humanTurn) + + const roundTurn = openTurn(root, { + kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1, + }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: resumed['revision'], action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index ebe71dc559..a880973ba5 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L150) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L159) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L314) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L267) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L207) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L217) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L178) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L229) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L281) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L244) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L191) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L168) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L255) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L291) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L301) ## agent-loop/* From 979fbbd96108a8fa6979992a1ac86cd14c2f612c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:07:15 +0800 Subject: [PATCH 054/132] fix(goal): harden driver cancellation settlement --- ...9-same-session-goal-round-driver.i18n.yaml | 4 +- ...26-07-19-same-session-goal-round-driver.md | 8 +-- ...07-19-same-session-goal-round-driver.zh.md | 8 +-- packages/goal/goal-session/README.md | 7 +-- packages/goal/goal-session/src/index.ts | 16 ++++-- .../goal-session/tests/goal-session.spec.ts | 49 ++++++++++++++++++- 6 files changed, 74 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index 9a5cf40202..d18d50594b 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-same-session-goal-round-driver.md: 2fc27ab28bff5e192e43d30afc6cd796fd3394e1 -2026-07-19-same-session-goal-round-driver.zh.md: e9610c94eca48fb4d06be505a8cdfffb6c272012 +2026-07-19-same-session-goal-round-driver.md: 94c0a870c46d6b7f618faf3f0a4b01c141bec358 +2026-07-19-same-session-goal-round-driver.zh.md: 9fdff05ffb722ef683bc06acee606f7897e5afc6 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index 2fc27ab28b..94c0a870c4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -39,7 +39,7 @@ The driver classifies one closed goal-owned turn as follows: | Turn result | Action | |---|---| | durable `completed` | continue while active/armed and under cap | -| broad cancellation / `aborted` | pause and disarm | +| cancellation of a reserved/admitted goal round, or its `aborted` result | pause and disarm | | `error` with code `RATE_LIMIT` | mark `usage-limited` | | other `error` | block | | `max-tokens` | block | @@ -52,9 +52,9 @@ No abnormal outcome requests an automatic retry. A later human prompt can ask to ### Durability and cancellation seam -Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver associates it with the exact attempt and disarms before the next idle decision. +Every `goal/changed` notification creates a checkpoint obligation. The driver awaits `ctx.sessions.flush(session)` before reserving work, then checks for a newer mutation, agent lifecycle change, or competing prompt. Turn-end flush failure is reported by the existing `agent/error` notification after `turn/end`; the driver finds that exact closed turn even when a concurrent one-shot injection appended a later turn, associates the failure with the exact attempt, and disarms before the next idle decision. -Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation and pause an active armed goal before the loop destroys the queued-work evidence. +Broad cancellation previously exposed only its effects after queues were cleared or the request aborted. The public agent vocabulary now includes observe-only `agent/cancel-requested(agent, reason)`. The concrete loop emits it for effective cancellation before either action; fused notification containment means a broken listener cannot veto cancellation. The goal driver uses this edge to clear its reservation before the loop destroys the queued-work evidence. When that reservation is a queued or admitted goal attempt, cancellation durably pauses the goal; when cancellation belongs to unrelated human work with no goal attempt, it only removes process-local activation. If the pause mutation throws, the driver falls back to disarming rather than allowing cancelled automatic work to restart. This is a coordination notification, not a second stop API. `Agent.cancel()` remains the only public broad cancellation verb, idle calls remain no-ops, and custom `Agent` implementations that claim the interface must honor the event ordering if consumers depend on it. @@ -68,7 +68,7 @@ An inbox acceptance can win the microtask race immediately before plugin unload ## Testing -The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. +The unit suite uses the real agent loop and session service with only the model scripted. It covers exact sequential admission and cap enforcement, load/resume inertness, every outcome classification, rate limiting, request errors, max tokens, downstream prompt veto, pre-admission and in-flight cancellation, unrelated-human cancellation, failed-pause fallback, human-input ordering, queued and downstream revision races, forged goal attribution, failed mutation and turn checkpoints including a later one-shot injection, scheduler and custom-agent failures, session-start reset, exact lifecycle retirement, and queued/running plugin teardown. The new driver source has per-file 100% statement, branch, function, and line coverage. A keyless Loader/stdio process test mounts the real goal domain, goal tools, goal driver, agent loop, persistence, and deterministic adapter through `cordis.yml`. One human turn creates a two-round goal; round one stops normally; round two reads the exact ref and completes it. The external JSONL assertion proves one session, round sources `1, 2`, unchanged round revision, final complete revision, five model steps, and no extra request after the terminal completion tool. diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index e9610c94ec..9fdff05ffb 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -39,7 +39,7 @@ Status: implemented | 轮次结果 | 动作 | |---|---| | 持久的 `completed` | 目标仍 active/armed 且未到上限时继续 | -| 广义取消 / `aborted` | 暂停并解除激活 | +| 取消已预留/接纳的目标回合,或该回合产生 `aborted` 结果 | 暂停并解除激活 | | 代码为 `RATE_LIMIT` 的 `error` | 标记为 `usage-limited` | | 其他 `error` | 阻塞 | | `max-tokens` | 阻塞 | @@ -52,9 +52,9 @@ Status: implemented ### 持久性与取消接缝 -每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;驱动器把它关联到精确尝试,并在下一次空闲决策前解除激活。 +每次 `goal/changed` 通知都会产生一个检查点义务。驱动器在预留工作前等待 `ctx.sessions.flush(session)`,随后检查是否出现了更新的变更、agent 生命周期变化或竞争提示词。轮次结束时的 flush 失败会在 `turn/end` 之后通过现有 `agent/error` 通知报告;即使并发的一次性注入已追加后续轮次,驱动器仍会找到该精确的已关闭轮次,把失败关联到精确尝试,并在下一次空闲决策前解除激活。 -广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿清除预留并暂停 active 且 armed 的目标,之后循环才销毁排队工作证据。 +广义取消此前只在队列已清除或请求已中止后暴露结果。公共 agent 词汇现在新增只观察的 `agent/cancel-requested(agent, reason)`。具体循环仅在取消有效时发出该事件,并且发生在清除队列和中止步骤之前;融合通知会隔离失败,因此损坏的监听器不能否决取消。目标驱动器利用该边沿在循环销毁排队工作证据前清除预留。若该预留是排队中或已接纳的目标尝试,取消会持久暂停目标;若取消属于没有目标尝试的无关人类工作,则只移除进程内激活态。若暂停变更抛错,驱动器会回退到解除激活,避免已取消的自动工作重新启动。 该通知是协调事件,不是第二个停止 API。`Agent.cancel()` 仍是唯一的公共广义取消动词,空闲调用仍是无操作;若消费者依赖此接缝,自定义 `Agent` 实现就必须满足该事件顺序。 @@ -68,7 +68,7 @@ Status: implemented ## 测试 -单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 +单元测试使用真实 agent loop 与会话服务,只对模型编写脚本。覆盖内容包括精确连续接纳和上限执行、加载与恢复的惰性、所有结果分类、限流、请求错误、最大 token、下游提示词否决、接纳前与执行中取消、无关人类工作取消、暂停失败回退、人类输入排序、排队时与下游修订竞争、伪造目标来源、变更与轮次检查点失败(包括后续一次性注入)、调度器与自定义 agent 失败、会话启动重置、精确生命周期退出,以及排队中和运行中的插件卸载。新驱动器源码达到逐文件 100% 语句、分支、函数和行覆盖率。 无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实目标领域、目标工具、目标驱动器、agent loop、持久化和确定性适配器。一个人类轮次创建两回合目标;第一回合正常停止;第二回合读取精确引用并完成目标。测试从外部检查 JSONL,证明只有一个会话、回合来源依次为 `1, 2`、回合修订号不变、最终完成修订正确、共有五个模型步骤,并且终止性的完成工具后没有额外请求。 diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index d962e84029..0f3f0861b6 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -30,7 +30,8 @@ The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, t | Durable turn outcome | Goal action | Automatic retry | |---|---|---| | `completed` with goal still active and armed | admit the next round, or mark `budget-limited` at the cap | yes | -| broad cancellation / `aborted` | `paused` | no | +| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no | +| cancellation with no goal-round attempt | keep durable phase; disarm activation | no | | `error` with `RATE_LIMIT` | `usage-limited` | no | | other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` | no | | durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no | @@ -39,11 +40,11 @@ A goal mutation made during its round supersedes settlement of the older revisio ## Lifecycle and durability -`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver disarms before another round can start. +`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start. Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. -Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step, allowing this plugin to pause and disarm the exact active goal. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. ## Model Experience diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 09ff85cac4..524bd5160a 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -276,8 +276,8 @@ export function apply(ctx: Context): void { /** Mark a post-turn persistence failure before idle scheduling can run. */ ctx.on('agent/error', (agent, turn) => { const state = stateFor(agent) - const last = agent.session.events.at(-1) - if (last?.type !== 'turn/end' || last.data.turn !== turn) return + const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn) + if (!closed) return if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn) disarm(state) }) @@ -312,11 +312,21 @@ export function apply(ctx: Context): void { }) ctx.on('agent/cancel-requested', (agent, reason) => { const state = stateFor(agent) + const attempt = state.attempt state.attempt = undefined state.competingQueued = false const goal = currentGoal(state) if (goal?.phase === 'active' && goal.activation === 'armed') { - applyOutcome(state, goal, { kind: 'pause', reason }) + if (attempt === undefined) { + disarm(state) + return + } + try { + applyOutcome(state, goal, { kind: 'pause', reason }) + } catch (error: unknown) { + ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) + disarm(state) + } } }) ctx.on('goal/changed', (agent) => { diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 13d6d9bf3f..a1ff54901a 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -402,12 +402,17 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) - it('disarms an admitted round whose closing durability checkpoint fails', async () => { + it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => { const test = await harness([textResponse('not durable')]) + let injected = false test.ctx.on('session/flush', (session) => { const lastStart = session.events.findLast(event => event.type === 'turn/start') if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message' - && lastStart.data.trigger.source.kind === 'goal') { + && lastStart.data.trigger.source.kind === 'goal' && !injected) { + injected = true + test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) return Promise.reject(new Error('round flush failed')) } }) @@ -421,6 +426,10 @@ describe('same-session goal driving', () => { expect(goal?.phase).toBe('active') expect(test.adapter.requests).toHaveLength(1) + const turns = test.agent.session.events.filter(event => event.type === 'turn/start') + const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal') + const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin') + expect(injectedTurn).toBeGreaterThan(goalTurn) }) it('blocks the goal when a custom agent rejects the otherwise valid send', async () => { @@ -559,6 +568,42 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) }) + it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => { + const test = await harness(['hang']) + test.agent.send([{ type: 'text', text: 'inspect something first' }]) + await waitForRequests(test.adapter, 1) + const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) + + test.agent.cancel('cancel the inspection') + await test.agent.whenIdle() + + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + id: created.id, + revision: created.revision, + phase: 'active', + activation: 'disarmed', + roundsStarted: 0, + }) + }) + + it('falls back to disarming when a cancelled reservation cannot be paused', async () => { + const test = await harness([]) + const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { + if (agent !== test.agent || info.source.kind !== 'goal') return + cancel() + vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { + throw new Error('pause failed') + }) + agent.cancel('cancel the reserved goal round') + }) + test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' }) + + const goal = await waitForGoal(test.ctx, test.agent, current => current?.activation === 'disarmed') + + expect(goal).toMatchObject({ phase: 'active', revision: 1, roundsStarted: 0 }) + expect(test.adapter.requests).toHaveLength(0) + }) + it('blocks admission when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false From 6f4f459c73b336d0b82ba88d7e3b895e345f697b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:30:51 +0800 Subject: [PATCH 055/132] test(goal): cover terminal update validation --- packages/goal/tool-goal/tests/tool-goal.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3950bf1e63..044fe2369a 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -384,6 +384,13 @@ describe('goal tool state transitions', () => { objective: 'not valid for pause', }, root.agent) expect(replacement.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const terminalUpdate = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'complete', + max_goal_rounds: 2, + }, root.agent) + expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) From 3a20b4c551016f048a1eb23dd7d50e933641589d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 10:48:58 +0800 Subject: [PATCH 056/132] dsh-translate-docs: delegate translation writing to a subagent worktree with a draft PR --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 2e152a0072..3a6aec6b5a 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 45c6edff41a7bc21c76aeeaf14d16af824c601de -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91ba7523705d1500150efe0eac9085ea980e80d6 +2026-07-02-bilingual-docs-and-pairing-gate.md: 865c1365939ae67e4e7dfa4bdf5c9edef5558891 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 14947f111b18a256cfe134e1e88efaad75844904 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 45c6edff41..865c136593 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent in a dedicated `.worktrees/` branch that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which stays on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 91ba752370..14947f111b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文档,按同 PR 规则留在该 PR 的分支上。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index d935c5c4b7..b857c7760f 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -5,6 +5,10 @@ description: Use when creating or updating the bilingual counterpart of a doc in # Translating DeepSeek-Harness docs +## Delegate to a subagent in a worktree + +When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. The subagent reads this skill and follows everything below; the sections from here on address the agent actually writing the translation. Exception: a counterpart update owed to an in-flight PR belongs on that PR's branch ([same-PR rule](../../../docs/i18n/README.md)) — the subagent works in that branch's worktree instead of opening a separate PR. + **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. ## Sources of truth (read, don't re-summarize) From f2206a3f280225f4b32f6819b55644b3e5df31c2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 10:55:03 +0800 Subject: [PATCH 057/132] Address review: fix exception path, recursion stop, terminology, anchor, description --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 3a6aec6b5a..531d21aacb 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 865c1365939ae67e4e7dfa4bdf5c9edef5558891 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 14947f111b18a256cfe134e1e88efaad75844904 +2026-07-02-bilingual-docs-and-pairing-gate.md: a3b012f7601a6eeb230e16b67ecac0e64432cb22 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8ccc596fff16b47d8990c28077e4f4083d807be4 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 865c136593..a3b012f760 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent in a dedicated `.worktrees/` branch that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which stays on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that works in a dedicated `.worktrees/` branch and opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 14947f111b..8ccc596fff 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文档,按同 PR 规则留在该 PR 的分支上。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index b857c7760f..1561995a1d 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,13 +1,13 @@ --- name: dsh-translate-docs -description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result --- # Translating DeepSeek-Harness docs ## Delegate to a subagent in a worktree -When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. The subagent reads this skill and follows everything below; the sections from here on address the agent actually writing the translation. Exception: a counterpart update owed to an in-flight PR belongs on that PR's branch ([same-PR rule](../../../docs/i18n/README.md)) — the subagent works in that branch's worktree instead of opening a separate PR. +When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From 2dd3b1dfba6921216ba7d0168a15ac277a4e4cd8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:24:02 +0800 Subject: [PATCH 058/132] fix(commands): harden registry and UI ordering --- ...7-19-plugin-command-registration.i18n.yaml | 4 +- .../2026-07-19-plugin-command-registration.md | 6 +- ...26-07-19-plugin-command-registration.zh.md | 6 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 5 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../advanced-toolchain/stdout.expected.jsonl | 2 +- .../bash-spill/stdout.expected.jsonl | 2 +- .../both-mode-turn/stdout.expected.jsonl | 2 +- .../cancel-tool-calls/stdout.expected.jsonl | 2 +- .../snapshots/cancel/stdout.expected.jsonl | 2 +- .../code-mode-turn/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../config-options/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../error-finish/stdout.expected.jsonl | 2 +- .../escalation-approved/stdout.expected.jsonl | 2 +- .../escalation-rejected/stdout.expected.jsonl | 2 +- .../snapshots/fs-edit/stdout.expected.jsonl | 2 +- .../fs-policy-reject/stdout.expected.jsonl | 2 +- .../fs-read-window/stdout.expected.jsonl | 2 +- .../snapshots/fs-read/stdout.expected.jsonl | 2 +- .../fs-terminal-card/stdout.expected.jsonl | 2 +- .../fs-write-overwrite/stdout.expected.jsonl | 2 +- .../snapshots/fs-write/stdout.expected.jsonl | 2 +- .../snapshots/handshake/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../hook-cc-pretool-ask/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../model-switching/stdout.expected.jsonl | 2 +- .../multi-turn/stdout.expected.jsonl | 2 +- .../parallel-tool-calls/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../repeat-tool-guard/stdout.expected.jsonl | 2 +- .../skill-load/stdout.expected.jsonl | 2 +- .../subagent-fork/stdout.expected.jsonl | 2 +- .../subagent-mixed/stdout.expected.jsonl | 2 +- .../subagent-multi/stdout.expected.jsonl | 2 +- .../subagent-spawn/stdout.expected.jsonl | 2 +- .../snapshots/text-turn/stdout.expected.jsonl | 2 +- .../snapshots/todo-plan/stdout.expected.jsonl | 2 +- .../tool-call-turn/stdout.expected.jsonl | 2 +- .../workflow-run/stdout.expected.jsonl | 2 +- .../workspace-context/stdout.expected.jsonl | 2 +- .../workspace-edit/stdout.expected.jsonl | 2 +- packages/ui/acp/README.md | 4 +- packages/ui/acp/src/index.ts | 59 +++++++++++++++++-- packages/ui/acp/tests/commands.spec.ts | 33 ++++++++--- packages/ui/acp/tests/edges.spec.ts | 5 +- packages/ui/commands/README.md | 2 +- packages/ui/commands/src/index.ts | 52 +++++++++++++--- packages/ui/commands/tests/commands.spec.ts | 53 +++++++++++++++-- packages/ui/tui/src/index.ts | 8 ++- packages/ui/tui/tests/tui.spec.ts | 29 +++++++++ website/zh-CN/api/harness/commands.md | 10 ++-- website/zh-CN/api/harness/events.md | 5 +- 67 files changed, 284 insertions(+), 101 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index e75c1f020f..48d880e666 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-plugin-command-registration.md: 5b599358036054c644985474c9c16518fcb5042a -2026-07-19-plugin-command-registration.zh.md: 08c9e13d122bc56c9feae35244e0f903c3b76b32 +2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d +2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 5b59935803..821f22405f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -26,7 +26,7 @@ A `CommandDefinition` contains a lowercase name without `/`, a non-empty descrip An unscoped registration is global. A command-injected plugin mounted beneath an agent context inherits that agent's scope key and lifetime, so its definition shadows a same-named global only for that exact agent. The child declares its own `commands` injection because `agent.ctx` intentionally inherits the core agent-loop dependency surface; adding a UI service to the loop merely to enable scoped registration would invert the dependency graph. -Registration and removal emit the unfiltered `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. +Registration and removal emit the unfiltered, non-vetoing `commands/change` registry notification. Adapters recompute each live agent's effective view rather than trying to infer which sessions a change affects. The registry contains and logs each observer failure independently, so a broken UI refresh cannot roll back another plugin's mutation or starve a later observer. Cordis ownership removes definitions when their producer, UI instance, or agent scope unloads, so HMR cannot leave stale discovery entries or handlers. ### Direct dispatch and cancellation @@ -42,7 +42,7 @@ Each submitted command owns an `AbortController`. TUI disposal aborts outstandin ### ACP mapping -The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. +The bridge follows the current [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). `session/new` and `session/load` emit the exact agent's full `available_commands_update` snapshot; a new session's RPC response introduces its server-generated id before the snapshot is enqueued. Every registry change emits a replacement snapshot for each live session. Names, descriptions, and optional unstructured-input hints map directly to `AvailableCommand`. ACP permits a command prompt to contain additional supported content blocks. The bridge applies its ordinary lossless `text` and `resource_link` flattening, then enters the command plane when the result starts with `/`. Unsupported prompt blocks are rejected by the existing capability boundary. Known commands execute directly; unknown or malformed slash input returns a direct error and never reaches the model. Successful text, expected errors, and thrown-failure diagnostics stream as live `agent_message_chunk` output and settle `end_turn`. @@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent ## Testing -The registry suite covers syntax boundaries, immutable normalization, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, change-notification rollback, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. +The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 08c9e13d12..7a2ed82eb1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -26,7 +26,7 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 无作用域注册是全局注册。挂载在智能体上下文之下并注入 `commands` 的插件会继承该智能体的作用域键与生命周期,因此其定义仅为该准确智能体遮蔽同名全局定义。子插件自行声明 `commands` 注入,因为 `agent.ctx` 有意只继承核心智能体循环的依赖界面;仅为了实现作用域注册而让循环依赖 UI 服务会倒置依赖图。 -注册和移除会发出未过滤的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 +注册和移除会发出未过滤、不可否决的 `commands/change` 注册表通知。适配器重新计算每个实时智能体的有效视图,而不尝试推断某次变更影响哪些会话。注册表会分别隔离并记录每个观察者失败,因此损坏的 UI 刷新无法回滚另一插件的变更,也无法阻止后续观察者。Cordis 所有权会在生产者、UI 实例或智能体作用域卸载时移除定义,因此 HMR 不会留下陈旧的发现项或处理器。 ### 直接分派与取消 @@ -42,7 +42,7 @@ TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit ### ACP 映射 -桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 +桥接遵循当前的 [ACP v1 斜杠命令契约](https://agentclientprotocol.com/protocol/v1/slash-commands)。`session/new` 与 `session/load` 发出准确智能体的完整 `available_commands_update` 快照;新会话的 RPC 响应会先引入服务端生成的 id,随后快照才会入队。每次注册表变更都会为每个实时会话发出替换快照。名称、描述和可选非结构化输入提示直接映射到 `AvailableCommand`。 ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的无损 `text` 与 `resource_link` 扁平化,然后在结果以 `/` 开头时进入命令平面。不支持的提示块由现有能力边界拒绝。已知命令直接执行;未知或格式错误的斜杠输入返回直接错误,绝不会到达模型。成功文本、预期错误和抛出失败的诊断作为实时 `agent_message_chunk` 输出流式发送,并以 `end_turn` 结束请求。 @@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的 ## 测试 -注册表测试覆盖语法边界、不可变规范化、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知回滚、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 +注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d40c155123..5a46c699e5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:217`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:246`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4c67788d82..3d23d95105 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -414,18 +414,19 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app ### `commands/change` — emit -A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. ```ts cordis-catalog /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:93`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index adff7993e5..113e4cc62d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -382,7 +382,7 @@ async execute( agent: Agent, surface: CommandSurface, line: string, signal: Abor Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) -Source: [`packages/ui/commands/src/index.ts:216`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b949abcb03..dfa2d1f11b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:93`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`emit`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index 50d688089e..6410d07df3 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index 875ff05303..739deae606 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 6c6e1d6158..d6964687d1 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 1e1131398b..4d770492c5 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index 0277ef2f90..c17327002f 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 2e06d6839a..003205b364 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index a41a9b8b3b..043607a926 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl index 4e2f95accf..eb9762f95a 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 50c05a4d18..e8b173bff2 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 80ab62b674..928ce6e2c9 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index e169016a1c..de4c76482c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index 5a60174abb..e99bb3a5bb 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index 451fe781dd..40223acda3 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index 39ddf764de..635a185739 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 2f2e1bc667..c2dcf2a731 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 3fed936cfc..4ce2099ace 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl index 51bb5f7c65..3244ee21c3 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 9e0d057b2a..f8c7edd015 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 927bb99975..e2e3bef55d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index 1390e424d6..dab4affa7e 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index 89e476b3e8..653a7340f8 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index b13a498bc0..2aa177932e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 5acbeb7202..262ce3e2ea 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index 623127e083..094e78be67 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..a6accd8f18 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 3f75a15f57..44a5da1302 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index bf478888b4..aad4e1a5b1 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index b8b6abe2c3..05fc598709 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index c8c34ab6ec..bc4266cc04 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 8e303806a9..73e123a566 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index 19ec84b738..a6accd8f18 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index 6107da23da..2c602e4cc1 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index ef02915d01..96df6e2ef4 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl index e3a5aacbeb..e50144c03e 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 57dd33f320..a9da0b4a4f 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 53048fa11d..946a91f75a 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl index 2232cd3a1c..53197fe328 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl index bc40beb3fe..f1c2fd0634 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index 8436971982..3e17783db5 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index a332ea7a7c..dd21b9674f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index ded1ec01cc..7bfd214919 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index d25e78d0db..4916b87bc8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index d1f79bacac..22e034b251 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index dc7b4dbe25..6171c640e4 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl index 0cae765592..ee40cae2fd 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index dce9237834..9183664a0f 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index 73604add34..24333f95a0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl index 8108f47bb0..3b233174fc 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index ff2610f1f5..38c5dcdfc1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3e74236f81..d74a0fc573 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -41,9 +41,9 @@ One id-keyed record map plus exact agent-object checks route every event, prompt ## Human commands -After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. +After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`. -ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). +ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands). ## Session config options diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3d4c060060..459dc50a43 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -17,6 +17,7 @@ import { PROTOCOL_VERSION, RequestError, type Agent as AcpAgent, + type AnyMessage, type AuthenticateRequest, type AvailableCommand, type CancelNotification, @@ -94,6 +95,34 @@ function renderThrown(value: unknown): string { } } +/** Return a server-created session id carried by an outbound success response. */ +function responseSessionId(message: AnyMessage): SessionId | undefined { + if (!('result' in message) || typeof message.result !== 'object' || message.result === null + || !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') { + return undefined + } + return SessionId(message.result.sessionId) +} + +/** Observe messages only after the wrapped ACP transport has written them. */ +function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream { + const writer = stream.writable.getWriter() + return { + readable: stream.readable, + writable: new WritableStream({ + async write(message) { + await writer.write(message) + onWritten(message) + }, + /* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream; + preserve the wrapped Stream contract for other consumers nonetheless */ + close: () => writer.close(), + abort: (reason: unknown) => writer.abort(reason), + /* v8 ignore stop */ + }), + } +} + /** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) @@ -393,6 +422,9 @@ export function apply(ctx: Context, config: AcpConfig): void { const sessions = new Map() // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() + // A new-session response introduces its server-generated id to the client; + // keep its initial command snapshot pending until that response is written. + const pendingCommandSnapshots = new Map() // Async creation checks this after awaits to avoid publishing after teardown. let closed = false // Each new or loaded session snapshots the latest connection capability. @@ -499,10 +531,23 @@ export function apply(ctx: Context, config: AcpConfig): void { }) } + /** Enqueue a new session's first command snapshot behind its written RPC response. */ + const announceInitialCommands = (message: AnyMessage): void => { + const sessionId = responseSessionId(message) + if (sessionId === undefined) return + const rec = pendingCommandSnapshots.get(sessionId) + if (rec === undefined) return + pendingCommandSnapshots.delete(sessionId) + notifyCommands(rec) + } + // Registration and HMR removal can affect global or one scoped view; refresh - // every bridge-owned session and let the registry resolve each exact agent. + // every announced bridge-owned session and let the registry resolve each + // exact agent. A pending new-session snapshot will read the latest registry. ctx.on('commands/change', () => { - for (const rec of sessions.values()) notifyCommands(rec) + for (const rec of sessions.values()) { + if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec) + } }) /** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */ @@ -711,7 +756,7 @@ export function apply(ctx: Context, config: AcpConfig): void { await handle.dispose() throw internalError('connection closed during session/new') } - sessions.set(sessionId, { + const record: SessionRecord = { agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), @@ -720,8 +765,9 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight: undefined, commandAbort: undefined, pendingSwitches: {}, - }) - notifyCommands(requireSession(sessionId)) + } + sessions.set(sessionId, record) + pendingCommandSnapshots.set(sessionId, record) const configOptions = configOptionsFor(handle.agent, directory) return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} } }, @@ -998,7 +1044,7 @@ export function apply(ctx: Context, config: AcpConfig): void { Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, ) - conn = new AgentSideConnection(makeAgent, stream) + conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands)) /** * Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach @@ -1036,6 +1082,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // installed yet) must observe this after its await and refuse to install a // post-teardown record. Set even when there are no live sessions. closed = true + pendingCommandSnapshots.clear() const recs = [...sessions.values()] sessions.clear() if (recs.length === 0) return Promise.resolve() diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts index 487b4b59f0..946ee67950 100644 --- a/packages/ui/acp/tests/commands.spec.ts +++ b/packages/ui/acp/tests/commands.spec.ts @@ -41,13 +41,15 @@ describe('ACP plugin commands', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({ - sessionUpdate: 'available_commands_update', - availableCommands: [{ - name: 'inspect', - description: 'Inspect the session', - input: { hint: '' }, - }], + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: [{ + name: 'inspect', + description: 'Inspect the session', + input: { hint: '' }, + }], + }) }) const dispose = harness.ctx.commands.register({ @@ -88,6 +90,23 @@ describe('ACP plugin commands', () => { }) }) + it('coalesces registry changes before a new session command snapshot is announced', async () => { + harness = await makeBridgeHarness({ storageDir }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + harness.ctx.commands.register({ + name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }), + }) + + await vi.waitFor(() => { + expect(commandUpdates(harness!, sessionId)).toHaveLength(1) + expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({ + availableCommands: [{ name: 'raced' }], + }) + }) + }) + it('executes a known single-text command directly and never sends it to the model', async () => { harness = await makeBridgeHarness({ storageDir }) const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' })) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 35dd54a634..fdbaf241e6 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true) + }) const before = harness.updates.length const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 2c1f358dfa..5b6c8b9425 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -4,7 +4,7 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal emits `commands/change` so live adapters can refresh discovery. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. `list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 97ec0c3587..529dea11b4 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -88,6 +88,7 @@ declare module 'cordis' { /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void @@ -115,6 +116,15 @@ function abortError(signal: AbortSignal): Error { return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted') } +/** Render arbitrary thrown values without trusting their string coercion. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Stop awaiting an uncooperative handler once its owning UI request aborts. */ function withAbort(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(abortError(signal)) @@ -133,7 +143,7 @@ function withAbort(promise: Promise, signal: AbortSignal): Promise { signal.removeEventListener('abort', onAbort) reject(error instanceof Error ? error - : new Error('command handler rejected with a non-Error value')) + : new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error })) }, ) }) @@ -144,17 +154,26 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (!COMMAND_NAME.test(definition.name)) { throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`) } + if (typeof definition.description !== 'string') { + throw new TypeError(`command "${definition.name}" description must be a string`) + } if (definition.description.trim().length === 0) { throw new TypeError(`command "${definition.name}" description must not be empty`) } if (typeof definition.handler !== 'function') { throw new TypeError(`command "${definition.name}" handler must be a function`) } - const input = definition.input === undefined - ? undefined - : Object.freeze({ hint: definition.input.hint }) - if (input !== undefined && input.hint.trim().length === 0) { - throw new TypeError(`command "${definition.name}" input hint must not be empty`) + const rawInput: unknown = definition.input + let input: CommandInputDescriptor | undefined + if (rawInput !== undefined) { + if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput) + || typeof rawInput.hint !== 'string') { + throw new TypeError(`command "${definition.name}" input hint must be a string`) + } + if (rawInput.hint.trim().length === 0) { + throw new TypeError(`command "${definition.name}" input hint must not be empty`) + } + input = Object.freeze({ hint: rawInput.hint }) } const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] if (surfaces.length === 0) { @@ -240,9 +259,9 @@ export class CommandService extends Service { yield () => { layer.delete(registered.definition.name) if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) - this.ctx.emit('commands/change') + this.notifyChange() } - this.ctx.emit('commands/change') + this.notifyChange() }.bind(this), 'commands.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order return dispose @@ -314,6 +333,23 @@ export class CommandService extends Service { } return layer } + + /** Notify every registry observer without making UI refresh load-bearing. */ + private notifyChange(): void { + // Cordis emit uses Array.map: one synchronous throw starves later listeners, + // and returned promises are discarded. Registry notifications are + // non-vetoing, so contain each callback independently. + for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) { + try { + const returned: unknown = callback() + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`) + } + } + } } export default CommandService diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index 67f9d3d0e2..e3da186492 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -107,7 +107,7 @@ describe('CommandService', () => { expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/) }) - it('emits on registration and disposal and rolls back when notification fails', async () => { + it('notifies on registration and disposal while containing broken observers', async () => { const ctx = await mount() const changed = vi.fn() ctx.on('commands/change', changed) @@ -116,11 +116,39 @@ describe('CommandService', () => { dispose() expect(changed).toHaveBeenCalledTimes(2) - const explode = ctx.on('commands/change', () => { throw new Error('observer failed') }) - expect(() => ctx.commands.register(command('rollback'))).toThrow('observer failed') - explode() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + ctx.on('commands/change', () => { throw new Error('observer threw') }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment + ctx.on('commands/change', () => Promise.reject(new Error('observer rejected'))) + const afterFailures = vi.fn() + ctx.on('commands/change', afterFailures) + const removeContained = ctx.commands.register(command('contained')) const { agent } = await mintAgentScope(ctx, 'a') - expect(ctx.commands.find(agent, 'tui', 'rollback')).toBeUndefined() + expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined() + expect(afterFailures).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw') + expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected') + }) + removeContained() + expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined() + expect(afterFailures).toHaveBeenCalledTimes(2) + }) + + it('rejects non-string descriptions and input hints with boundary diagnostics', async () => { + const ctx = await mount() + expect(() => ctx.commands.register({ + ...command('description-type'), + description: undefined, + } as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string') + expect(() => ctx.commands.register({ + ...command('hint-type'), + input: { hint: 42 }, + } as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string') + expect(() => ctx.commands.register({ + ...command('input-type'), + input: null, + } as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string') }) it('passes exact invocation context and detaches valid handler results', async () => { @@ -187,7 +215,20 @@ describe('CommandService', () => { handler: () => Promise.reject('not an Error'), }) await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) - .rejects.toThrow('command handler rejected with a non-Error value') + .rejects.toThrow('command handler rejected with a non-Error value: not an Error') + + const hostile = { toString(): string { throw new Error('cannot render') } } + ctx.commands.register({ + name: 'reject-hostile', + description: 'Reject an unrenderable value', + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization + handler: () => Promise.reject(hostile), + }) + await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal)) + .rejects.toMatchObject({ + message: 'command handler rejected with a non-Error value: ', + cause: hostile, + }) }) it('observes an abort triggered synchronously inside the handler', async () => { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2ace80165c..2fe7398931 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1229,6 +1229,7 @@ export function createTuiChat( commandControllers.add(controller) void ctx.commands.execute(agent, 'tui', text, controller.signal).then( (result) => { + if (disposed) return if (result === undefined) { appendNotice(`Unknown command: ${text}`, 'warning') } else if (result.text !== undefined && result.text !== '') { @@ -1342,7 +1343,12 @@ export function createTuiChat( } catch (error: unknown) { disposed = true detachListeners() - void commandFiber.dispose() + void commandFiber.dispose().catch( + /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */ + (cleanupError: unknown) => { + ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${renderThrown(cleanupError)}`) + }, + ) clearStatus() disposeUserInteraction() ui.stop() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index bf63aeaeb9..be67e117cd 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -510,6 +510,35 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.fiber.dispose() }) + it('suppresses a successful plugin result that settles as TUI disposal starts', async () => { + const result = await setup() + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + let resolveCommand!: (result: { kind: 'success'; text: string }) => void + result.ctx.commands.register({ + name: 'late-success', + description: 'Resolve while the TUI closes', + surfaces: ['tui'], + handler: () => new Promise((resolve) => { + resolveCommand = resolve + started() + }), + }) + + result.terminal.send('/late-success') + result.terminal.send('\r') + await ready + resolveCommand({ kind: 'success', text: 'must not render after disposal' }) + // Let the command boundary accept the result before disposal, but leave the + // TUI continuation queued so the success-side disposal guard owns the race. + await Promise.resolve() + await result.controller.dispose() + await tick() + + expect(result.terminal.output).not.toContain('must not render after disposal') + await result.ctx.fiber.dispose() + }) + it('cancels before /exit while running and handles agent errors/disposal', async () => { const result = await setup({ status: 'running' }) result.terminal.send('/exit') diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md index 6f8ed33dfa..e59a2203da 100644 --- a/website/zh-CN/api/harness/commands.md +++ b/website/zh-CN/api/harness/commands.md @@ -6,7 +6,7 @@ Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L216) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235) ### ctx.commands.register(definition) @@ -25,7 +25,7 @@ Register a global or calling-agent-scoped command. **Returns** the exact effect disposer that unregisters this definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L229) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248) ### ctx.commands.list(agent, surface) @@ -46,7 +46,7 @@ List the effective immutable command descriptors for one agent and surface. **Returns** name-sorted descriptors after scoped shadowing and surface filtering. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L257) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276) ### ctx.commands.find(agent, surface, name) @@ -69,7 +69,7 @@ Resolve one effective command definition. **Returns** the scoped shadow or global definition when visible on the surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L272) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291) ### ctx.commands.execute(agent, surface, line, signal) @@ -94,4 +94,4 @@ Parse and execute a known command without sending it to the model. **Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L285) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 7ad96d6a81..35bf5480c7 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -482,14 +482,15 @@ Ask composed answerers for one decision. Return an outcome to claim the request /** * A command was registered or unregistered. This is an unfiltered registry * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. * @mode emit */ 'commands/change'(): void ``` -A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. +A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L93) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94) ## fs/* From 4d037613b1045e3c458f8a7524071056c660898f Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:31:27 +0800 Subject: [PATCH 059/132] Drop worktree/branch mechanics from the delegation instruction; just use a subagent --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 531d21aacb..0bac554646 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: a3b012f7601a6eeb230e16b67ecac0e64432cb22 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8ccc596fff16b47d8990c28077e4f4083d807be4 +2026-07-02-bilingual-docs-and-pairing-gate.md: effcd68810cd3179e7cc5f52730e0dd8b9129e0e +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 683530479d40458c40be99cf1470a4893e97f296 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index a3b012f760..effcd68810 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that works in a dedicated `.worktrees/` branch and opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever change triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 8ccc596fff..683530479d 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意改动上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 1561995a1d..77cc941f0c 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -5,9 +5,9 @@ description: Use when creating or updating the bilingual counterpart of a doc in # Translating DeepSeek-Harness docs -## Delegate to a subagent in a worktree +## Delegate to a subagent -When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. +When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From 33fadcf08d6ffb1bd7a39df4afaeefaa142f1fd9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:46:16 +0800 Subject: [PATCH 060/132] Drop the draft-PR requirement; delegation is just: use a subagent --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 0bac554646..a1d901ad15 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: effcd68810cd3179e7cc5f52730e0dd8b9129e0e -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 683530479d40458c40be99cf1470a4893e97f296 +2026-07-02-bilingual-docs-and-pairing-gate.md: 3be1d5d8fd9dba20cfca34c79cb01d89fad8097a +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a8aa8812934e755fe0175c8f3f20d194e4d24b4a diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index effcd68810..3be1d5d8fd 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever change triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 683530479d..a8aa881293 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意改动上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 77cc941f0c..f1edf20611 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -7,7 +7,7 @@ description: Use when creating or updating the bilingual counterpart of a doc in ## Delegate to a subagent -When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. +When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From a6e96c47f37e12e1cb1a3a36514ba6aa060fe09e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 11:53:49 +0800 Subject: [PATCH 061/132] review fix: pin ordinary-send batching removal --- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +-- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 3 +- .../2026-07-17-one-send-one-turn.zh.md | 3 +- docs/architecture.md | 6 ++-- docs/cordis-catalog/events.md | 30 ++++++++--------- docs/core-data-structures/core.md | 14 +++++--- docs/core-data-structures/persistence.md | 2 +- docs/core-data-structures/session.md | 5 +-- docs/event-producer-consumer.md | 30 ++++++++--------- docs/persistence-catalog.md | 33 ++++++++++--------- packages/core/agent-loop/src/loop.ts | 4 +-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 14 +++++--- packages/core/session/src/types.ts | 5 +-- .../stdio-demo/tests/built-bin.e2e.ts | 17 ++++++++-- packages/ui/stdio/src/index.ts | 6 ++-- website/zh-CN/api/harness/events.md | 30 ++++++++--------- 18 files changed, 118 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 125ef0946c..ef5bb5847a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,7 +12,7 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later accepted prompt remains an independent queued turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer @@ -29,7 +29,7 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -106,7 +106,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item whose claimed turn contains no other ordinary message; consecutive claimed items wait for the prior ordinary turn's checkpoint to settle, but can share one `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). Cancellation, disposal, or a pre-start failure may drop an item without a turn. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..87f9e1fdd8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 675182422e..ba1b2e6156 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -362,7 +362,9 @@ interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -385,9 +387,10 @@ interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void @@ -429,7 +432,8 @@ interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9d4dc2ee6f..18ef172ef8 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d73f3a21fb..89a8972b98 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -34,8 +34,9 @@ interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5912accb71 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index baf8f61057..82859e2eb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) ### `bash/*` @@ -258,7 +258,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `hook/*` @@ -336,7 +336,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) ### `request/*` @@ -350,7 +350,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `steering/*` @@ -363,7 +363,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `step/*` @@ -374,7 +374,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +383,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `todo/*` @@ -396,7 +396,7 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) ### `tool/*` @@ -413,7 +413,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -457,7 +457,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `turn/*` @@ -466,15 +466,16 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ ```ts persistence-catalog /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } ``` Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -503,4 +504,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index d7b3fb2ee7..0024623e8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -99,8 +99,8 @@ export interface LoopHandle { /** * Drive queued messages as independent durable turns until disposal. Plugin - * failures end the current turn without terminating the driver. The caller establishes the - * `ctx.agents.withInitiator()` boundary before entry; package-private + * failures end the current turn without terminating the driver. The caller + * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private * orchestration recovers that exact Agent and captures its Session locally. * @param ctx - the plugin context the loop reaches its initiating Agent, * events (agent/…, session/flush), and services (systemPrompt, llm, tools) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f11023a248..fc6382ab2d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 25ed34d694..f9bf8f2825 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,8 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } @@ -97,7 +98,9 @@ export interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -120,9 +123,10 @@ export interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 11d0fb129b..df73f3fb50 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -187,8 +187,9 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..c2bb459cc9 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -104,8 +104,8 @@ async function makeConsumer( return dir } -/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */ -function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> { +/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */ +function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> { return new Promise((resolve, reject) => { // --expose-internals: the cordis Loader resolves bare plugin specifiers via // its internal module loader (active only under this flag); demo:echo passes @@ -128,7 +128,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st }, 25_000) child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.write(`${line}\n`) + child.stdin.write(`${input}\n`) child.stdin.end() }) } @@ -167,6 +167,17 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(code).toBe(0) }, 30_000) + it('runs two synchronously piped lines as two ordinary turns', async () => { + consumer = await makeConsumer('TWO-TURNS ready.') + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond') + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('[main turn 1]') + expect(stdout).toContain('You said: "first"') + expect(stdout).toContain('[main turn 2]') + expect(stdout).toContain('You said: "second"') + expect(code).toBe(0) + }, 30_000) + it('boots when optional spill plugins are loaded from a built consumer install', async () => { consumer = await makeConsumer( 'SPILL-OK ready.', diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 6f73d948bf..720284518a 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -164,9 +164,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // immediately — no turn will ever start, so there is nothing to wait // for. (Gating on an observed 'running' here would hang forever.) // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Two subtleties this handles: the loop batches - // several queued messages into ONE turn (one idle), so we don't count - // sends; and agent.send() does NOT synchronously flip status to + // AFTER having run. Later lines may steer the active turn, and consecutive + // queued turns can share one running interval, so we don't count inputs; + // agent.send() also does NOT synchronously flip status to // 'running', so requiring an observed 'running' first (`sawRunning`) // avoids exiting in the gap before the turn starts and dropping work. let stdinClosed = false diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..0f5f1132ba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) ## agent-loop/* From 4dd6266c53116f59e7604f828919c3103991c7c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:54:34 +0800 Subject: [PATCH 062/132] docs(commands): refresh command event catalog --- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0ec3d2926b..f5892671cb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -812,7 +812,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'commands/change', mode: 'emit', signature: '\'commands/change\'(): void', - jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * @mode emit\n */', + jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, { From 4ce2202721febe40056046a3f58b8a8c04dbc4aa Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:56:08 +0800 Subject: [PATCH 063/132] Bound the delegation section with a header so "skip this section" is well-defined --- .agents/skills/dsh-translate-docs/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index f1edf20611..8786eb5e6b 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -9,6 +9,8 @@ description: Use when creating or updating the bilingual counterpart of a doc in When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation. +## What this skill is + **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. ## Sources of truth (read, don't re-summarize) From 491dafc78597100736e80230a42facde7fcb9445 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:11:06 +0800 Subject: [PATCH 064/132] fix(goal): close human command surface gaps --- .../2026-07-19-human-goal-command.i18n.yaml | 4 ++-- .../feature/2026-07-19-human-goal-command.md | 10 +++++----- .../feature/2026-07-19-human-goal-command.zh.md | 10 +++++----- docs/config-catalog.md | 2 +- packages/examples/stdio-demo/README.md | 4 ++-- packages/examples/stdio-demo/src/index.ts | 4 ++-- .../stdio-demo/tests/stdio-agent.spec.ts | 16 ++++++++++++---- packages/goal/command-goal/README.md | 4 ++-- packages/goal/command-goal/src/index.ts | 9 +++++++-- .../goal/command-goal/tests/command-goal.spec.ts | 8 +++++--- pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 12 files changed, 47 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index e1a79533b9..78bb900b27 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-human-goal-command.md: b195cd6d5d6e50672f2433e2f845e650d825a1f9 -2026-07-19-human-goal-command.zh.md: 84a920c206b875d14349a69da556e945125b53c3 +2026-07-19-human-goal-command.md: f458feae3bed8ef7b0ace6b8baaf5ae8b43e4cc8 +2026-07-19-human-goal-command.zh.md: 2fd79c2f50490e077cc69e93705a7b7bb242a082 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index b195cd6d5d..f458feae3b 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -14,7 +14,7 @@ The command must also respect the goal design's two kinds of state. Durable phas `@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition for the TUI and ACP surfaces. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. -The command follows the compact current Codex shape documented by the [official developer-command reference](https://learn.chatgpt.com/docs/developer-commands?surface=cli): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. +The command follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. The commit permalink makes the researched grammar durable even as Codex evolves. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. ### Grammar and lifecycle verbs @@ -30,9 +30,9 @@ Control words are ASCII-case-insensitive after outer whitespace trimming. They a ### Output and failure boundary -Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or resumable stopped goal offers resume, budget-limited and completed states do not advertise an invalid resume. +Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or resumable stopped goal offers resume, a budget-limited goal explains that the agent must raise `maxGoalRounds` before resume, and a completed goal offers replacement or clear. -Expected `GoalError` failures become `CommandResult.error`, so invalid human operations receive a stable direct response and never enter model history. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. +Expected `GoalError` failures become one stable, branded-id-free `CommandResult.error`, so domain diagnostics do not leak compare-and-set internals into the human surface and invalid operations never enter model history. The current status supplies the actionable state-specific recovery. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. Generic slash input, status text, and errors are not persisted. Successful goal mutations use the existing `Agent.inject()` path, producing the raw model-visible goal snapshot or clear tombstone that persistence already owns. The command therefore changes no session format and introduces no second audit record that could disagree with the domain event. @@ -40,11 +40,11 @@ Generic slash input, status text, and errors are not persisted. Successful goal `agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation. -The terminal and ACP app bundles make the opposite product choice. They default `goals` to the owner defaults, mount the goal domain, model tools, same-session driver, command registry, and this producer, and accept `goals: false` as one coherent opt-out. The TUI and ACP bridge then discover the same definition through the generic registry. The line-oriented stdio mode does not consume the command plane; a typed `/goal` there remains an ordinary human prompt that the model may interpret through its separately authorized goal tools. +The interactive app bundles make the opposite product choice. ACP defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer. The terminal app enables the same goal stack by default but mounts the producer only for TUI mode; line-oriented stdio does not consume the command plane, so a typed `/goal` there remains an ordinary human prompt that the model may interpret through its separately authorized goal tools. Both apps accept `goals: false` as one coherent stack opt-out. The Python SDK runtime closure ships this producer alongside ACP, commands, and the goal stack so an external `cordis.yml` can compose the same command surface. ## Testing -The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, discovery on both surfaces, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, armed/disarmed presentation, round-budget presentation, expected domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, terminal/ACP defaults, coherent opt-out, forwarded domain/tool config, command discovery, and the expanded model-tool assembly. The keyless ACP snapshots pin the resulting `/goal` discovery metadata and goal tool schemas in the shipped app composition. +The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, discovery on both surfaces, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, armed/disarmed presentation, budget-exhaustion recovery guidance, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI/ACP defaults, readline producer absence, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. The keyless ACP snapshots pin the resulting `/goal` discovery metadata and goal tool schemas in the shipped app composition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 84a920c206..2fd79c2f50 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -14,7 +14,7 @@ Status: implemented 位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它为 TUI 和 ACP 表面注册一个全局 `goal` 定义。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 -该命令遵循[官方开发者命令参考](https://learn.chatgpt.com/docs/developer-commands?surface=cli)所记录的当前 Codex 紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 +该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 ### 语法与生命周期动词 @@ -30,9 +30,9 @@ Status: implemented ### 输出与失败边界 -状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或可恢复的停止目标提供恢复,受预算限制和已完成状态不会宣传非法的恢复操作。 +状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或可恢复的停止目标提供恢复,受预算限制的目标说明 agent 必须先提高 `maxGoalRounds` 才能恢复,已完成目标则提供替换或清除。 -预期的 `GoalError` 失败会变为 `CommandResult.error`,因此非法人类操作会收到稳定的直接响应,且绝不会进入模型历史。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 +预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向人类表面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 通用斜杠输入、状态文本与错误不会持久化。成功的目标变更使用现有 `Agent.inject()` 路径,产出持久化本就拥有的原始模型可见目标快照或清除墓碑。因此该命令不会改变会话格式,也不会引入可能与领域事件不一致的第二份审计记录。 @@ -40,11 +40,11 @@ Status: implemented `agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在一个相关物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 -终端和 ACP 应用包作出相反的产品选择。它们默认让 `goals` 使用所有者默认值,挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方,并接受 `goals: false` 作为一致的整体退出选项。随后,TUI 与 ACP 桥通过通用注册表发现同一个定义。行式 stdio 模式不消费命令平面;在那里输入的 `/goal` 仍是普通人类提示词,模型可以通过独立授权的目标工具解释它。 +交互式应用包作出相反的产品选择。ACP 默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方。终端应用默认启用相同目标栈,但只在 TUI 模式挂载本生产方;行式 stdio 不消费命令平面,因此在那里输入的 `/goal` 仍是普通人类提示词,模型可以通过独立授权的目标工具解释它。两个应用都接受 `goals: false` 作为一致的整体退出选项。Python SDK 运行时闭包把本生产方与 ACP、命令及目标栈一并交付,使外部 `cordis.yml` 能组合相同命令表面。 ## 测试 -生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、两个表面的发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、已激活/未激活展示、回合预算展示、预期领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、终端/ACP 默认值、一致退出、转发的领域/工具配置、命令发现与扩展后的模型工具组装。无密钥 ACP 快照固定了交付应用组合中的 `/goal` 发现元数据和目标工具 schema。 +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、两个表面的发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、已激活/未激活展示、预算耗尽恢复提示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI/ACP 默认值、readline 不挂载生产方、一致退出、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。无密钥 ACP 快照固定了交付应用组合中的 `/goal` 发现元数据和目标工具 schema。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0087f417dd..a8840b2bca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -891,7 +891,7 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and TUI command. */ goals?: agentCore.GoalConfig | false /** * If set, the pre-created agent RESUMES this persisted session id instead of diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 4f452aa6d9..0f275485b0 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -12,7 +12,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha |---|---| | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-commands` | the human-command registry consumed by the TUI front door and optional command plugins | -| `@deepseek-ai/dsh-command-goal` | the direct `/goal` producer; the app enables the spine's persisted-goal stack with it | +| `@deepseek-ai/dsh-command-goal` | the direct `/goal` producer mounted only for the TUI front door; readline retains the model-mediated goal path | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | @@ -38,7 +38,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | -| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | +| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and the TUI `/goal` producer | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | terminal banner / TUI subtitle | | `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 307a0db483..5e399a56ca 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -101,7 +101,7 @@ export interface Config { toolBash?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and TUI command. */ goals?: agentCore.GoalConfig | false /** * If set, the pre-created agent RESUMES this persisted session id instead of @@ -152,7 +152,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) const goals = config.goals ?? {} if (mode === 'readline') ctx.plugin(ConsoleExporter) ctx.plugin(CommandService) - if (goals !== false) ctx.plugin(commandGoal) + if (mode === 'tui' && goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(UserInteractionService) if (mode === 'tui') { diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index f20860f1eb..2480cb704d 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -118,17 +118,25 @@ describe('dsh-stdio-demo app', () => { calls.length = 0 stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', model: 'mock', workspaceContext: false, goals: false, ui: { mode: 'readline' }, + provider: 'mock', model: 'mock', workspaceContext: false, goals: false, ui: { mode: 'tui' }, + }, true) + expect(calls.map(call => call.name)).toContain('ui-tui') + expect(calls.map(call => call.name)).not.toContain('command-goal') + expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: false }) + + calls.length = 0 + stdioAgent.composeTerminalApp(ctx, { + provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' }, }, false) expect(calls.map(call => call.name)).toContain('ui-stdio') expect(calls.map(call => call.name)).toContain('ConsoleExporter') expect(calls.map(call => call.name)).not.toContain('ui-tui') expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: false }) + expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: {} }) }) it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) + const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false, ui: { mode: 'readline' } }) // The spine services (brought up by the agent-spine-demo bundle) are all present. expect(ctx.get('agents')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() @@ -145,7 +153,7 @@ describe('dsh-stdio-demo app', () => { expect(agent?.id).toBe(agent?.session.id) expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) - expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeDefined() + expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeUndefined() await ctx.fiber.dispose() }) diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index c971297cd3..2fa7323789 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -15,7 +15,7 @@ Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin r Control words are case-insensitive only when they occupy the complete input. Every other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. The goal domain trims and validates objectives. Because the generic command plane has no modal editor or confirmation primitive, `edit` takes its replacement inline and an unfinished replacement returns a direct error instructing the user to edit or clear. -Expected domain rejections become direct command errors. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin. +Expected domain rejections become stable direct command errors without exposing branded ids or revisions. Unexpected implementation failures still reject dispatch so adapters can report them as command failures. Generic command text and output remain live UI state; every accepted mutation is persisted and made model-visible by `dsh-goal` rather than by this plugin. ## Composition @@ -30,7 +30,7 @@ The producer injects `commands` and `goals`. A custom app mounts their owners pl name: '@deepseek-ai/dsh-command-goal' ``` -The terminal and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. +The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The terminal app's readline mode keeps the model-mediated goal stack but does not mount this producer because that front door does not consume commands. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation. ## Model Experience diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 3fff035adc..a8e73cba7e 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -69,7 +69,7 @@ function commandHint(goal: GoalView): string { case 'usage-limited': return '/goal edit , /goal resume, /goal clear' case 'budget-limited': - return '/goal edit , /goal clear' + return '/goal edit , /goal clear; after the agent raises the round cap, /goal resume' case 'complete': return '/goal , /goal clear' /* v8 ignore next 2 -- the active branch and every non-active phase are handled above */ @@ -149,7 +149,12 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman default: return assertNever(command, 'goal command') } } catch (error: unknown) { - if (error instanceof GoalError) return { kind: 'error', text: error.message } + if (error instanceof GoalError) { + return { + kind: 'error', + text: 'The goal command is not valid for the current state. Run /goal to view available commands.', + } + } throw error } } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index e13f5e9822..a3d48857c4 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -197,8 +197,10 @@ describe('/goal human command', () => { const test = await harness() await run(test, ' work') const redundantResume = await run(test, ' RESUME') - expect(redundantResume.kind).toBe('error') - expect(redundantResume.text).toContain('already active and armed') + expect(redundantResume).toEqual({ + kind: 'error', + text: 'The goal command is not valid for the current state. Run /goal to view available commands.', + }) const paused = await run(test, ' PAUSE') expect(paused.kind).toBe('success') expect(paused.text).toContain('Goal paused') @@ -238,7 +240,7 @@ describe('/goal human command', () => { goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal)) const limited = await run(test) expect(limited.text).toContain('Status: limited by round budget') - expect(limited.text).not.toContain('/goal resume') + expect(limited.text).toContain('after the agent raises the round cap, /goal resume') goal = test.ctx.goals.complete(test.agent, ref(goal)) const complete = await run(test) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a45c8b505..9ff42e490e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2656,6 +2656,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../packages/ui/commands diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index e49d7a5a03..d189399e21 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -18,6 +18,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", From aeb48cc5f3123c22d202763c91544f53a9492b45 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:17:09 +0800 Subject: [PATCH 065/132] Render error cause chains at every diagnostic seam A TUI run against an unreachable endpoint failed with only 'fetch failed': undici wraps transport failures in a bare TypeError whose diagnosis lives on .cause, and every diagnostic seam rendered only error.message. The readline front door additionally rendered failed turns as pure silence. - dsh-llm: new errorChain(value) renders the full cause chain and AggregateError members with circular/hostile-coercion containment. - llm-deepseek: pre-response transport failures throw LlmError('NETWORK') naming the endpoint and chaining the fetch TypeError; aborts keep their DOMException so the loop still classifies them as cancellation. - agent-loop: durable turn/end error messages and logger warnings render through errorChain; local renderThrown copies removed. - ui-stdio: failure turn/end reasons now render ([turn failed ], [turn aborted], [turn rejected], output-token-limit); startup-failure logs use errorChain. - ui-tui: agent/error notices and the startup-failure line use errorChain. --- ...20-error-cause-chain-diagnostics.i18n.yaml | 6 +++ ...026-07-20-error-cause-chain-diagnostics.md | 37 ++++++++++++++ ...-07-20-error-cause-chain-diagnostics.zh.md | 37 ++++++++++++++ docs/config-catalog.md | 6 +-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/src/agent.ts | 8 +--- packages/core/agent-loop/src/index.ts | 17 ++----- packages/core/agent-loop/src/loop.ts | 13 +++-- .../tests/config-session-id.spec.ts | 12 ++--- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 48 +++++++++++++------ .../llm/llm-deepseek/tests/adapter.spec.ts | 35 +++++++++++++- packages/llm/llm/README.md | 1 + packages/llm/llm/src/error.ts | 45 +++++++++++++++++ packages/llm/llm/tests/service.spec.ts | 45 +++++++++++++++++ packages/ui/stdio/README.md | 2 +- packages/ui/stdio/src/index.ts | 29 ++++++----- packages/ui/stdio/tests/stdio.spec.ts | 39 ++++++++++++++- packages/ui/tui/src/index.ts | 16 ++----- packages/ui/tui/tests/tui.spec.ts | 4 +- website/zh-CN/api/harness/agent-loop.md | 8 ++-- website/zh-CN/api/harness/events.md | 2 +- 24 files changed, 334 insertions(+), 84 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml new file mode 100644 index 0000000000..630a1b05e6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-error-cause-chain-diagnostics.md: 2d860d0e966158dd9ec12b45f88e3b031e1cb35a +2026-07-20-error-cause-chain-diagnostics.zh.md: 6eac19dd08d50e4662d53889577b5e3ddabaafdd diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md new file mode 100644 index 0000000000..2d860d0e96 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -0,0 +1,37 @@ +# Agent Note: Render error cause chains at every diagnostic seam + +Status: implemented + +English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md) + +## Problem + +A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: + +1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic seam in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line. +2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. + +## Decision + +- `dsh-llm` exports `errorChain(value)`: renders a thrown value with its full `cause` chain (`outer: inner: …`) and AggregateError members (`msg [m1; m2]`), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays on `HarnessError.code`. +- The DeepSeek adapter wraps a pre-response transport failure in `LlmError('NETWORK')` naming the configured `baseURL` and chaining the original `TypeError` as `cause`. An aborted request keeps its `DOMException` so the loop still classifies it as cancellation, not a provider failure. +- Every diagnostic seam renders through `errorChain` instead of `error.message`/`String(error)`: the agent-loop's durable `turn/end` error message (`errorData`), its logger warnings, the TUI's `agent/error` notice and startup-failure line, and `dsh-stdio`'s startup-failure log lines. The per-package `renderThrown` copies in `dsh-agent-loop`, `dsh-stdio`, and `dsh-tui` are deleted in favor of the one shared renderer. +- `dsh-stdio` renders failure `turn/end` reasons: `[turn failed ] `, `[turn aborted] `, `[turn rejected] `, `[turn interrupted by a previous process exit]`, and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends. + +`errorChain` lives in `dsh-llm` beside `HarnessError` for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge. + +## Alternatives considered + +**Chain rendering inside each error's constructor (bake the cause into `message`).** Rejected: it double-renders once consumers also walk `cause` (the first draft of the adapter fix produced `… fetch failed: bad port: fetch failed: bad port`), and it destroys the structured chain for consumers that want to route on the inner error. + +**A `cause`-aware logger exporter only.** Rejected: the durable `turn/end` reason and the TUI notice are not logger lines; the masked message would persist in the session log — the single durable record of an in-turn failure — and in the primary UI surface. + +**Per-package `renderThrown` upgrades.** Rejected: three packages already carried near-identical private copies; upgrading each separately entrenches the duplication the shared renderer removes. + +## Consequences + +- A transport failure now reads `DeepSeek API request to failed: fetch failed: connect ECONNREFUSED …` in the TUI notice, the readline transcript, and the persisted session log, at the cost of longer diagnostic strings. +- Durable `turn/end` error messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry no `cause` (for such errors `errorChain(err)` equals `err.message`); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain. +- `errorChain` renders `message` without the class name (`String(error)` rendered `Error: `), so a bare `TypeError` in a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these seams. +- `dsh-stdio` output for failed turns is no longer silent; piped consumers that parsed the transcript see new `[turn …]` lines. +- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, `dsh-workflow-workerthread`, and `cli-demo` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient. diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md new file mode 100644 index 0000000000..6eac19dd08 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 在每个诊断接缝处渲染错误 cause 链 + +Status: implemented + +[English](2026-07-20-error-cause-chain-diagnostics.md) | 中文 + +## Problem + +TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: + +1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断接缝都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 +2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 + +## Decision + +- `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 +- DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('NETWORK')`,写明配置的 `baseURL` 并把原始 `TypeError` 链为 `cause`。被中止的请求保留其 `DOMException`,使循环仍将其归类为取消而非 provider 失败。 +- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 +- `dsh-stdio` 渲染失败的 `turn/end` reason:`[turn failed ] `、`[turn aborted] `、`[turn rejected] `、`[turn interrupted by a previous process exit]` 以及输出 token 上限通知。未知的 merge 扩展 kind 按普通 turn 结束处理。 + +`errorChain` 与 `HarnessError` 一样放在 `dsh-llm` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 + +## Alternatives considered + +**在每个错误的构造函数里渲染链(把 cause 烤进 `message`)。** 否决:当消费者同时遍历 `cause` 时会双重渲染(适配器修复的第一版产出了 `… fetch failed: bad port: fetch failed: bad port`),并且破坏了想按内层错误路由的消费者所需的结构化链。 + +**只做一个感知 `cause` 的日志导出器。** 否决:持久化的 `turn/end` reason 和 TUI 通知不是日志行;被掩盖的消息会留在会话日志——回合内失败的唯一持久记录——以及主要 UI 表面里。 + +**逐包升级 `renderThrown`。** 否决:三个包已经各自持有几乎相同的私有副本;分别升级只会固化共享渲染器所要消除的重复。 + +## Consequences + +- 传输失败现在在 TUI 通知、readline transcript 和持久化会话日志里显示为 `DeepSeek API request to failed: fetch failed: connect ECONNREFUSED …`,代价是更长的诊断字符串。 +- 持久化的 `turn/end` 错误消息包含 cause 细节。现有 snapshot fixture 字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。 +- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: `),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些接缝上,链细节被判断为比类名更有价值。 +- `dsh-stdio` 对失败回合的输出不再沉默;解析 transcript 的管道消费者会看到新的 `[turn …]` 行。 +- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread`、`cli-demo` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 74d0c6ba7e..c9af8506dd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -101,7 +101,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -822,7 +822,7 @@ export interface Config { } ``` -Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts) +Source: [`packages/ui/stdio/src/index.ts:34`](../packages/ui/stdio/src/index.ts) ## `@deepseek-ai/dsh-stdio-demo` @@ -1266,7 +1266,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:101`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..005853d171 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -366,7 +366,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..e22553caa0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { - const rendered = renderThrown(error) + const rendered = errorChain(error) const err = error instanceof Error ? error : new Error(rendered) this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`) agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) @@ -446,7 +446,3 @@ export class ReactLoopAgent implements Agent { } } -/** Render an ordinary thrown value for the error event and log. */ -function renderThrown(value: unknown): string { - return value instanceof Error ? value.message : String(value) -} diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 2a77afc983..24e18d5c32 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -20,7 +20,7 @@ import type { ResumeAgentOptions, SessionStartSource, } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-llm' +import { errorChain } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -41,15 +41,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -/** Render an arbitrary thrown value without letting coercion escape containment. */ -function renderThrown(value: unknown): string { - try { - return String(value) - } catch { - return '' - } -} - /** Factory-level ownership of every preparing or live transaction. */ class FactoryOwnership { private accepting = true @@ -475,16 +466,16 @@ export class AgentLoop extends Service implements AgentFactory { error: unknown, ): void { if (!this.ownership.isActive()) return - this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) + this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) void Promise.resolve(returned).catch((listenerError: unknown) => { - this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`) + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`) }) } catch (listenerError: unknown) { - this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`) + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`) } } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 9016c16d9b..a737117420 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' @@ -56,9 +56,12 @@ function finishError(finish: FinishReason): RequestError | undefined { /** * Build the `{ message, code? }` part of an error payload, omitting the * `code` key entirely when absent (exactOptionalPropertyTypes-correct). + * The durable message renders the full cause chain: `turn/end` is the single + * durable record of an in-turn failure, so a wrapper message alone (e.g. + * `fetch failed`) would lose the diagnosis the session log exists to keep. */ function errorData(err: RequestError): { message: string; code?: string } { - return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } + return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} } } /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ @@ -151,7 +154,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { } catch (error: unknown) { // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) - ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) + ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`) try { events.emit('agent/error', turn, 0, err) } catch { /* contained: a throwing agent/error listener must not kill the driver */ } @@ -388,7 +391,7 @@ async function runTurn( ) } catch (recoveryError: unknown) { ctx.logger.warn( - `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`, + `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, ) } handle.setAbort(undefined) @@ -552,7 +555,7 @@ async function runTurn( } catch (error: unknown) { // The turn is closed, so report the failed flush live rather than append outside a turn. const err = toError(error) - ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`) + ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`) try { events.emit('agent/error', turn, step, err) } catch { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index cadc176aea..5f9bf3dd07 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -217,14 +217,14 @@ describe('config-driven session id', () => { }) await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( - 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', + 'config-driven restore of "stdio-exact-failure" failed: persistence index failed', )) expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) expect(warn).toHaveBeenCalledWith( - 'agent "main": config-start-failed listener threw: Error: failure observer failed', + 'agent "main": config-start-failed listener threw: failure observer failed', ) await expect.poll(() => warn).toHaveBeenCalledWith( - 'agent "main": config-start-failed listener rejected: Error: async failure observer failed', + 'agent "main": config-start-failed listener rejected: async failure observer failed', ) expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() warn.mockRestore() @@ -256,13 +256,13 @@ describe('config-driven session id', () => { await expect.poll(() => failures).toEqual([unrenderable]) expect(warn).toHaveBeenCalledWith( - 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', ) expect(warn).toHaveBeenCalledWith( - 'agent "main": config-start-failed listener threw: ', + 'agent "main": config-start-failed listener threw: ', ) await expect.poll(() => warn).toHaveBeenCalledWith( - 'agent "main": config-start-failed listener rejected: ', + 'agent "main": config-start-failed listener rejected: ', ) await ctx.fiber.dispose() }) diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 27bf4b626a..ee4c705560 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -42,7 +42,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. A transport failure before any response (DNS, refused connection, TLS, proxy) throws `NETWORK` naming the configured endpoint and chaining fetch's `TypeError: fetch failed` as `cause`, so `errorChain` renders the underlying diagnosis; an abort keeps its `DOMException` so the loop classifies it as cancellation. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. ## Testing diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 918c8eee82..66520a83d4 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -81,23 +81,43 @@ export class DeepSeekAdapter extends LlmAdapter { async * stream(options: GenerateOptions): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) + // Prepared outside the try so the NETWORK label below covers exactly the + // transport boundary, never a serialization failure. + const payload = JSON.stringify(body) + const headers = { + 'authorization': `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + 'accept': 'text/event-stream', + ...attributionHeaders(), + ...options.sessionId !== undefined + ? { 'x-deepseek-harness-session-id': String(options.sessionId) } + : {}, + } // TODO(http): adopt the Cordis HTTP service when shared transport configuration // outweighs its additional runtime dependencies. - const response = await fetch(`${this.options.baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'authorization': `Bearer ${this.options.apiKey}`, - 'content-type': 'application/json', - 'accept': 'text/event-stream', - ...attributionHeaders(), - ...options.sessionId !== undefined - ? { 'x-deepseek-harness-session-id': String(options.sessionId) } - : {}, - }, - body: JSON.stringify(body), - ...options.signal ? { signal: options.signal } : {}, - }) + let response: Response + try { + response = await fetch(`${this.options.baseURL}/chat/completions`, { + method: 'POST', + headers, + body: payload, + ...options.signal ? { signal: options.signal } : {}, + }) + } catch (error: unknown) { + // An aborted request rethrows its original rejection (the signal's abort + // reason) so the loop classifies it as cancellation, not a provider failure. + if (options.signal?.aborted) throw error + // fetch wraps every transport failure (DNS, refused connection, TLS, + // proxy) in a bare `TypeError: fetch failed` whose actionable detail + // lives on `cause`. Wrapping with the endpoint and chaining the cause + // lets `errorChain` render the full diagnosis at every reporting seam. + throw new LlmError( + `DeepSeek API request to ${this.options.baseURL} failed`, + 'NETWORK', + { cause: error }, + ) + } if (!response.ok) { let message = `DeepSeek API error (HTTP ${response.status})` diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 954a0ecebd..b92f96dbda 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' @@ -228,6 +228,39 @@ describe('DeepSeekAdapter against a mock server', () => { expect(httpErrorCode(418)).toBe('HTTP_418') }) + it('wraps a transport failure in NETWORK with the fetch cause chain in the message', async () => { + // Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed` + // whose actionable detail (ECONNREFUSED) lives on `cause`. + const ctx = await harness('http://127.0.0.1:1') + let caught: unknown + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + caught = error + } + expect(caught).toBeInstanceOf(LlmError) + const llmError = caught as LlmError + expect(llmError.code).toBe('NETWORK') + expect(llmError.message).toContain('http://127.0.0.1:1') + expect(llmError.cause).toBeInstanceOf(TypeError) + // The chain renderer reaches the transport diagnosis through the cause. + expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/) + }) + + it('keeps an abort rejection unwrapped so the loop classifies it as cancellation', async () => { + const controller = new AbortController() + controller.abort() + const ctx = await harness('http://127.0.0.1:1') + let caught: unknown + try { + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal }) + } catch (error: unknown) { + caught = error + } + expect(caught).not.toBeInstanceOf(LlmError) + expect((caught as Error).name).toBe('AbortError') + }) + it('throws EMPTY_RESPONSE when the response has no body', async () => { const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index f9142dbc52..82ca7f901c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -48,6 +48,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. - `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result. - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. ### Real adapters diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 8c1c736492..c351060ee5 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -62,6 +62,51 @@ export function isContextWindowExceededError(detail: string): boolean { || EXCEEDS_MODEL_CONTEXT.test(detail) } +/** + * Render a thrown value with its full `cause` chain and AggregateError + * members, so transport wrappers like undici's `TypeError: fetch failed` + * surface the underlying failure instead of masking it. Diagnostic-surface + * rendering only (messages, notices, logs) — never parse the result; route on + * {@link HarnessError.code}. + * @param value - the caught value (`unknown` in catch clauses). + * @returns the outermost message first, each cause appended with `: ` (skipped + * when it repeats the wrapper message verbatim), and AggregateError members + * bracketed and `; `-joined. + */ +export function errorChain(value: unknown): string { + // Tracks the active recursion path (entries removed on exit), so only true + // cycles are flagged and a diamond-shared cause still renders in full. + const path = new Set() + const render = (current: unknown): string => { + if (path.has(current)) return '' + path.add(current) + try { + if (!(current instanceof Error)) return String(current) + const message = current.message === '' ? current.name : current.message + const members = current instanceof AggregateError && current.errors.length > 0 + ? ` [${current.errors.map(render).join('; ')}]` + : '' + const causeText = current.cause === undefined || current.cause === null + ? '' + : render(current.cause) + // Wrappers like `new HarnessError(String(value), code, { cause: value })` + // repeat their cause verbatim; rendering it again would only add noise. + const cause = causeText === '' || causeText === message ? '' : `: ${causeText}` + return `${message}${members}${cause}` + } catch { + // Only hostile coercion or hostile accessors (a throwing toString / + // Symbol.toPrimitive on a non-Error, or a throwing message/name/cause/ + // errors getter on an Error subclass): this renderer feeds UI notices + // and logs, so nothing may escape. Inner frames catch their own throws, + // so only the hostile node collapses, not the whole chain. + return '' + } finally { + path.delete(current) + } + } + return render(value) +} + /** * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). * @param value - the caught value (`unknown` in catch clauses). diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..67b90d1c2f 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { + errorChain, GenerateOptions, HarnessError, isContextWindowExceededError, @@ -80,6 +81,50 @@ describe('LlmService', () => { expect(isContextWindowExceededError('context window size must be positive')).toBe(false) }) + it('errorChain renders the full cause chain of a wrapped transport failure', () => { + const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') }) + expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443') + }) + + it('errorChain renders AggregateError members (Happy Eyeballs multi-address failures)', () => { + const aggregate = new AggregateError( + [new Error('connect ECONNREFUSED ::1:443'), new Error('connect ECONNREFUSED 127.0.0.1:443')], + '', + ) + const wrapped = new TypeError('fetch failed', { cause: aggregate }) + expect(errorChain(wrapped)).toBe( + 'fetch failed: AggregateError [connect ECONNREFUSED ::1:443; connect ECONNREFUSED 127.0.0.1:443]', + ) + }) + + it('errorChain survives non-Error values, hostile coercion, and circular causes', () => { + expect(errorChain('plain string')).toBe('plain string') + expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('') + const circular = new Error('outer') + circular.cause = circular + expect(errorChain(circular)).toBe('outer: ') + // A hostile accessor collapses only its own node, not the whole chain. + const hostileNode = new Error('node') + Object.defineProperty(hostileNode, 'message', { get() { throw new Error('hostile getter') } }) + expect(errorChain(new Error('outer', { cause: hostileNode }))).toBe('outer: ') + // A diamond-shared (non-cyclic) cause renders in full on both paths. + const shared = new Error('shared') + const diamond = new AggregateError([new Error('a', { cause: shared }), new Error('b', { cause: shared })], 'agg') + expect(errorChain(diamond)).toBe('agg [a: shared; b: shared]') + }) + + it('errorChain falls back to the error name, skips empty aggregates, and stops at null causes', () => { + expect(errorChain(new TypeError('', { cause: null }))).toBe('TypeError') + expect(errorChain(new AggregateError([], 'all failed'))).toBe('all failed') + }) + + it('errorChain collapses a cause that repeats the wrapper message verbatim', () => { + // The `new HarnessError(String(value), code, { cause: value })` normalization + // pattern repeats its cause; rendering it twice would only add noise. + const wrapped = new HarnessError('boom', 'UNKNOWN', { cause: 'boom' }) + expect(errorChain(wrapped)).toBe('boom') + }) + it('routes stream() to the registered adapter', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index eda7d00b8b..869dd7b4df 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-stdio -The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. +The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. Failed turns render their durable `turn/end` reason — `[turn failed ]`, `[turn aborted]`, `[turn rejected]`, `[turn interrupted …]`, or the output-token-limit notice — so a provider or network failure is never silent; unknown merge-extended reason kinds fall through as ordinary turn ends. This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 6f73d948bf..4f7695d906 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -15,6 +15,7 @@ import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-agent-loop' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -62,15 +63,6 @@ function isTTYPair(input: Readable, output: Writable): boolean { return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) } -/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ -function renderThrown(value: unknown): string { - try { - return String(value) - } catch { - return '' - } -} - interface PendingQuestion { request: AskUserQuestionRequest questionIndex: number @@ -138,6 +130,21 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } else if (event.type === 'turn/end') { if (inReasoning) output.write('\x1B[0m') inReasoning = false + // Failure reasons must reach the terminal: turn/end is the durable record + // of an in-turn failure, and without this line a failed turn renders as + // silence. Merge-extensible unknown kinds fall through as ordinary ends. + const { reason } = event.data + if (reason.kind === 'error') { + output.write(`\n[turn failed${reason.code === undefined ? '' : ` ${reason.code}`}] ${reason.message}`) + } else if (reason.kind === 'aborted') { + output.write(`\n[turn aborted]${reason.reason === undefined ? '' : ` ${reason.reason}`}`) + } else if (reason.kind === 'rejected') { + output.write(`\n[turn rejected] ${reason.reason}`) + } else if (reason.kind === 'max-tokens') { + output.write('\n[turn hit the output-token limit]') + } else if (reason.kind === 'interrupted') { + output.write('\n[turn interrupted by a previous process exit]') + } output.write('\n> ') } else if (event.type === 'tool/call') { const { name: toolName, arguments: args } = event.data @@ -235,7 +242,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt queuedInput.length = 0 submittedWork = sawRunning if (dropped > 0) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`) + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${errorChain(error)}`) } maybeExit() }) @@ -395,7 +402,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const text = line.trim() if (!text) return if (failedStartup !== undefined) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`) + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${errorChain(failedStartup.error)}`) return } const agent = target diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index a3069462ff..10fa956f25 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -227,6 +227,41 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\n> ') }) + it('renders failure turn/end reasons so a failed turn is not silent', async () => { + const { ctx, out } = await setup() + const session = makeSession('main') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 1, time: 0, + data: { turn: 1, reason: { kind: 'error', step: 1, message: 'fetch failed: connect ECONNREFUSED', code: 'NETWORK' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn failed NETWORK] fetch failed: connect ECONNREFUSED') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 2, time: 0, + data: { turn: 2, reason: { kind: 'error', step: 1, message: 'uncoded failure' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn failed] uncoded failure') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 3, time: 0, data: { turn: 3, reason: { kind: 'aborted', reason: 'user cancelled' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn aborted] user cancelled') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 4, time: 0, data: { turn: 4, reason: { kind: 'aborted' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn aborted]\n> ') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 5, time: 0, data: { turn: 5, reason: { kind: 'rejected', reason: 'policy veto' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn rejected] policy veto') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 6, time: 0, data: { turn: 6, reason: { kind: 'max-tokens' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn hit the output-token limit]') + ctx.emit('session/event', session, { + type: 'turn/end', seq: 7, time: 0, data: { turn: 7, reason: { kind: 'interrupted' } }, + } as SessionEvent) + expect(out.text()).toContain('[turn interrupted by a previous process exit]') + }) + it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() // No target exists, so the event's durable identity is the label. @@ -814,7 +849,7 @@ describe('createStdioChat input', () => { await new Promise(r => setImmediate(r)) expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', ) }) @@ -902,7 +937,7 @@ describe('createStdioChat EOF exit', () => { await flushExit() expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', ) expect(exit).toHaveBeenCalledWith(0) }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1c3fc1315c..ae0c480dde 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -35,6 +35,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -191,15 +192,6 @@ function displayText(text: string): string { `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) } -/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ -function renderThrown(value: unknown): string { - try { - return String(value) - } catch { - return '' - } -} - /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body @@ -1263,7 +1255,9 @@ export function createTuiChat( const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { if (subject !== agent) return liveErrors.add(`${turn}:${step}`) - appendNotice(error.message, 'error') + // Full cause chain: wrapper messages like `fetch failed` carry the + // actionable transport detail on `cause`. + appendNotice(errorChain(error), 'error') }) const disposeAgent = ctx.on('agent/disposed', (subject) => { if (subject !== agent) return @@ -1330,7 +1324,7 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi if (settled || failedSessionId !== sessionId) return settled = true stopWaiting() - runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${renderThrown(error)}\n`)) + runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`)) runtime.exit(1) } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 27200e0fa9..8e0df2cc75 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -864,7 +864,7 @@ describe('terminal mounting', () => { expect(terminal.output).toBe('') expect(exit).not.toHaveBeenCalled() ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007')) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n') expect(exit).toHaveBeenCalledWith(1) const session = ctx.sessions.create(SessionId('main-session')) @@ -892,7 +892,7 @@ describe('terminal mounting', () => { }) expect(terminal.started).toBe(0) - expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') + expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: \n') expect(exit).toHaveBeenCalledWith(1) await ctx.fiber.dispose() }) diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md index 9a43a1ba14..750f6a49dc 100644 --- a/website/zh-CN/api/harness/agent-loop.md +++ b/website/zh-CN/api/harness/agent-loop.md @@ -6,7 +6,7 @@ Concrete agent factory and driver service. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L407) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L398) ### ctx.agentLoop.create(id, options?, meta?) @@ -31,7 +31,7 @@ Create an agent and session under one caller-supplied identity, owned by the acc **Returns** the published running agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L542) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L533) ### ctx.agentLoop.createAgent(ownerCtx, options) @@ -52,7 +52,7 @@ Create an owned agent on a caller-supplied session id. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L564) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L555) ### ctx.agentLoop.resume(ownerCtx, options) @@ -73,4 +73,4 @@ Resume an owned agent from the configured persistence service. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L596) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L587) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..a708c8ab22 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -423,7 +423,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers - `sessionId` — exact shared agent/session identity that failed startup. - `error` — persistence, setup, or publication failure. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L362) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L353) ## approval/* From d59149a22183f329665157c49ff26fe11aef7ff8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 12:51:44 +0800 Subject: [PATCH 066/132] review fix: restore idle after between-turn cancellation --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 14 ++--- .../2026-07-17-one-send-one-turn.zh.md | 16 +++--- docs/architecture.md | 2 +- docs/cordis-catalog/events.md | 8 +-- docs/core-data-structures/session.md | 2 +- docs/i18n/style-samples.md | 4 +- docs/persistence-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 5 +- packages/core/agent-loop/tests/cancel.spec.ts | 54 +++++++++++++++++++ packages/core/agent/src/types.ts | 6 +-- packages/core/session/src/types.ts | 2 +- packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/dispose.spec.ts | 3 +- packages/ui/acp/tests/multi-session.spec.ts | 2 +- website/zh-CN/api/harness/events.md | 12 ++--- 18 files changed, 102 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index ab47d77028..9760e026a2 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 41c2eff49e45e649fd773f02656b799bae8dbaf1 -2026-07-17-one-send-one-turn.zh.md: 69b091aca5c8aeb71b8312b8b4f888fd3742c610 +2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d +2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 41c2eff49e..9534574e76 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -22,18 +22,18 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. ## Verification - Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. -Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. +Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 69b091aca5..e3cd3c9f6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,32 +8,32 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 diff --git a/docs/architecture.md b/docs/architecture.md index 2bbd9e8388..d920117fb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. -A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the prior turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. +A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the preceding claimed ordinary turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 87f9e1fdd8..6d73230a4b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -125,14 +125,14 @@ Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/t ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 89a8972b98..8e2eaac1a3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -43,7 +43,7 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 2d9de63bc6..dd970b7c12 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns. +> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要根据操作次数推断操作与轮次一一对应。 +**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 82859e2eb0..666a77e112 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,7 +498,7 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (queued message drained at turn start). */ +/** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..d1ea28850f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -652,8 +652,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one drained prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent draining its inbox.\n * @param content - the drained message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { name: 'agent/queued', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..4161cad92b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Pre-step cancellation re-parks without emitting a status transition. + // Already-idle pre-start cancellation still must settle queued-work waiters. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 0024623e8d..66d4a65d69 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ + /** Settle idle waiters when pre-running cancellation finds the status already idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -126,6 +126,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { + // setStatus settles running→idle; the explicit settle covers the + // already-idle pre-start path where that transition is deduplicated. + handle.setStatus('idle') handle.settleIdle() continue } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 634421ed09..c582b4c943 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -140,6 +140,60 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + const cancelled = Promise.withResolvers() + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + // The first hop runs before runLoop resumes from runTurn; the second lands + // before its resolved waitForQueued continuation checks cancellation. + queueMicrotask(() => { + queueMicrotask(() => { + agent.cancel('between turns') + cancelled.resolve(undefined) + }) + }) + }) + + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + + send(agent, 'first') + send(agent, 'queued tail') + await cancelled.promise + + expect(agent.status).toBe('idle') + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(userTexts(agent)).toEqual(['first']) + + let idleResolved = false + void agent.whenIdle().then(() => { idleResolved = true }) + await Promise.resolve() + expect(idleResolved).toBe(true) + + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'idle steer' }]) + await idle + + expect(statuses).toEqual(['running', 'idle', 'running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'idle steer']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f9bf8f2825..37481dbc1d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -207,10 +207,10 @@ declare module 'cordis' { */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index df73f3fb50..3733877258 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -196,7 +196,7 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index efcc4bb18d..ca7510048b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -830,7 +830,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // not-yet-started prompt never runs, while a prompt accepted afterward // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or - // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt + // pending prompt (multi-session isolation). + // We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 5cbdb6859b..8baf076d58 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -223,7 +223,8 @@ describe('acp bridge — disposal & HMR safety', () => { it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { // The factory returns a per-agent AgentHandle whose dispose() tears down - // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // EXACTLY that agent + its session — the registry's per-handle isolation + // contract. Create two agents // directly through the registry factory (the same path the ACP bridge uses), // dispose one handle, and assert the other survives, registered and // queryable, with its session still in the store. diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index 0881fe4199..efeb00f9ad 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -14,7 +14,7 @@ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[ .join('') } -describe('acp bridge — RFC 011 multi-session isolation', () => { +describe('acp bridge — multi-session isolation', () => { let storageDir: string let harness: BridgeHarness | undefined diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 0f5f1132ba..01313a79df 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -141,10 +141,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending ```ts website-api /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall @@ -152,10 +152,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. -- `agent` — the agent draining its inbox. -- `content` — the drained message's blocks, as queued. +- `agent` — the agent whose turn claimed the message. +- `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) From 60eea35df5d4cdba6e21e10d9a44728e513f49e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:56:09 +0800 Subject: [PATCH 067/132] fix(ralph): harden execution boundaries --- ...-fresh-agent-ralph-workflow-tool.i18n.yaml | 4 +- ...6-07-19-fresh-agent-ralph-workflow-tool.md | 19 +- ...7-19-fresh-agent-ralph-workflow-tool.zh.md | 19 +- docs/config-catalog.md | 2 + docs/core-data-structures/workflow.md | 7 +- docs/tool-catalog.md | 2 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 2 +- .../both-mode-turn/system-prompt.expected.md | 4 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../code-mode-turn/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../model-switching/system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 4 +- .../system-prompt.expected.md | 4 +- .../tool-schemas.expected.json | 4 +- .../skill-load/system-prompt.expected.md | 2 +- .../skill-load/tool-schemas.expected.json | 2 +- .../text-turn/system-prompt.expected.md | 2 +- .../text-turn/tool-schemas.expected.json | 2 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../workspace-edit/system-prompt.expected.md | 2 +- .../workspace-edit/tool-schemas.expected.json | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/workflow/tool-ralph/README.md | 14 +- packages/workflow/tool-ralph/src/index.ts | 102 ++++++++-- .../tool-ralph/tests/integration.spec.ts | 179 +++++++++++++++++- .../tool-ralph/tests/tool-ralph.spec.ts | 67 ++++++- .../workflow/workflow-workerthread/README.md | 4 +- .../workflow-workerthread/src/index.ts | 35 +++- .../workflow-workerthread/src/runtime.ts | 2 +- .../tests/session.spec.ts | 1 + .../tests/source-worker.compat.spec.ts | 8 + .../tests/workflow-workerthread.spec.ts | 88 ++++++++- packages/workflow/workflow/README.md | 4 +- packages/workflow/workflow/src/types.ts | 5 + 37 files changed, 535 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml index 797f9adef6..f0453085c5 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-fresh-agent-ralph-workflow-tool.md: b00f433db62ed1a44a14baab056b2fd64a2695c4 -2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 4c5de168ad3a1c05b318078394f6eb1d253d4c74 +2026-07-19-fresh-agent-ralph-workflow-tool.md: 159ad7e39602d8b84ffafdb396ad1083f9c73009 +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: bb6025952ada35526459101d53b3ea1fea622163 diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md index b00f433db6..159ad7e396 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md @@ -18,29 +18,35 @@ The tool is foreground-only. The calling agent parents every child for cwd and l ### Per-run workflow provider route -`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider and uses the result for every `agent()` call in the run. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged. +`WorkflowStartRequest` gains optional `subagentProvider`. The worker-thread engine resolves that explicit per-run value before falling back to its configured provider, requires the selected normalized route to be registered before publishing the run, and uses it for every `agent()` call. The script cannot observe or replace this route. The ordinary `workflow` tool leaves the field unset and exposes no new model argument, so general workflow behavior and provider policy stay unchanged. The Ralph plugin's `subagentProvider` defaults to `spawn`. Immediately before a call it requires the named provider to exist, support structured output, and report `inheritsParentContext: false`; a fork-like or incapable provider fails loudly before workflow start. Provider lookup remains call-time because effect-scoped provider registration can change under HMR. +### Per-run workflow child ceiling + +`WorkflowStartRequest` also gains optional `maxTotalAgents`. The worker-thread engine requires a positive safe integer no greater than its configured deployment ceiling and installs the resolved value in that run's worker limits before publishing the run. Ralph passes its resolved `maxRounds` as this ceiling, so the fixed loop's round budget and the generic runaway-child backstop cannot disagree. The ordinary workflow tool leaves the field unset and keeps the engine default. + ### Ralph rounds and handoff The hierarchy is Ralph Run → Ralph Round → fresh child Turn → Step. One Ralph round creates exactly one child through the selected provider. Spawn gives that child a distinct session with no seed while preserving the parent's cwd, so the shared working tree is the durable authority and neither parent conversation nor prior child history enters the request. The fixed prompt passes only the immutable objective, current round and cap, a workspace-as-authority instruction, and the previous structured report. A `RalphRoundReport` contains `status: continue | complete | blocked`, `summary`, `evidence`, `nextSteps`, and `blocker`. Strings must be normalized; `continue` requires next steps and no blocker, `complete` requires evidence with no next steps or blocker, and `blocked` requires a concrete blocker. The script validates semantics and serialized size before the report can become the next handoff; the consumer validates the materialized terminal value again across the workflow seam. -`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` defaults to `16384`. Both are positive safe-integer config values, and oversized reports fail rather than being silently truncated. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started. +`maxRounds` defaults to `256` and is also the deployment ceiling for a call override. `maxHandoffChars` and `maxResultChars` each default to `16384`. All are positive safe-integer config values. Oversized handoffs fail rather than being silently truncated; `maxResultChars` separately bounds the complete successful parent-facing text, including its envelope and truncation marker, without changing cross-round state. After a `continue` report at the last permitted round, the fixed script returns `budget-limited`; `complete` and `blocked` return immediately with the final report and number of rounds started. + +The workflow language maps a normally settled but unsuccessful child to `null`. The fixed script detects that value before report validation and returns `round-failed` with the failed round plus the last successful handoff when one exists; the tool turns it into an error instead of misclassifying it as a malformed report or budget exhaustion. Ralph adds no retry policy. Fatal provider-start, transport, worker, and workflow errors remain generic workflow failures because the workflow seam does not carry a recoverable child report on those paths. ### Model and UI surface The model may supply only `objective` and optional `maxRounds`; provider selection, report schema, handoff cap, and script are deployment-owned. A fixed prompt section says to use `ralph` only when the direct human explicitly asks for Ralph or fresh-agent iteration, and distinguishes it from same-session goals, bounded delegation, and general fan-out workflows. This is guidance rather than a new goal UX state machine. -ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. The parent transcript retains the original tool call and one bounded terminal report, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows. +ACP and terminal presentation use a generic `ralph` card whose raw input is the objective. Successful completion and blocker envelopes say that a worker reported the outcome rather than presenting it as independent certification. The parent transcript retains the original tool call and one bounded successful terminal report or an error, not intermediate child messages. Shipped headless, REPL/TUI, and ACP compositions load the plugin beside the existing workflow engine; JSON-RPC remains unchanged because its default composition does not expose workflows. ## Testing -Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing, all three terminal outcomes, malformed and oversized boundary values, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove that a per-run provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node. +Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node. -A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, terminal completion, and disposal of both children. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. +A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. ## Alternatives considered @@ -56,7 +62,7 @@ A keyless real-stack integration drives the fixed script through the actual work - Goal rounds and Ralph rounds stay different concepts: the former is one same-session continuation turn, while the latter is one fresh child inside a foreground workflow. - The workspace becomes authoritative cross-round memory, so workers must inspect and verify it rather than trusting a narrative handoff. - A generous round ceiling permits substantial autonomous work, while deployment config still bounds child count and every handoff remains size-limited. -- Provider routing becomes an explicit workflow start concern without expanding the script or ordinary workflow tool surface. +- Provider routing and a lowerable per-run child ceiling become explicit workflow start concerns without expanding the script or ordinary workflow tool surface. ## Known limitations and deferred work @@ -64,4 +70,5 @@ A keyless real-stack integration drives the fixed script through the actual work - Runs are foreground and process-local. Background collection, persistence/resume, scheduling, and restart recovery are absent. - Round count is the only aggregate budget. Token, currency, elapsed-time, and provider-usage budgets remain separate future policy. - One round creates one child. Within-round fan-out, evaluator/worker role separation, dynamic provider or model selection, and cross-run journals are deferred. +- An ordinary child failure ends the run without retry, while preserving the failed round and last successful handoff. Fatal workflow infrastructure failures can end before the fixed script returns that state; adding retry or richer failure transport requires separate policy and seam design. - Prompt guidance asks models not to invoke Ralph recursively; a structural child-tool restriction would require a separately designed workflow child-policy surface. diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md index 4c5de168ad..bb6025952a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -18,29 +18,35 @@ Status: implemented ### 每次运行的工作流 provider 路由 -`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider,并把结果用于该运行中的每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。 +`WorkflowStartRequest` 新增可选的 `subagentProvider`。工作线程引擎先解析这个显式的每次运行值,再回退到引擎配置的 provider;在发布运行前,它要求所选规范化路由已注册,并把结果用于每次 `agent()` 调用。脚本无法观察或替换此路由。普通 `workflow` 工具不设置该字段,也不暴露新的模型参数,因此通用工作流行为和 provider 策略保持不变。 Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要求具名 provider 已存在、支持结构化输出且报告 `inheritsParentContext: false`;类似 fork 或能力不足的 provider 会在工作流启动前响亮失败。provider 查找保留在调用期,因为效果作用域内的 provider 注册可能随 HMR 改变。 +### 每次运行的工作流子 agent 上限 + +`WorkflowStartRequest` 还新增可选的 `maxTotalAgents`。工作线程引擎要求它是正安全整数且不高于已配置的部署上限,并在发布运行前把解析值装入该运行的工作线程限制。Ralph 把解析后的 `maxRounds` 作为此上限,因此固定循环的轮次预算不会与通用失控子 agent 后备限制冲突。普通工作流工具不设置该字段并保留引擎默认值。 + ### Ralph 轮次与交接 层级为 Ralph 运行 → Ralph 轮次 → 全新子 agent 回合 → 步骤。每个 Ralph 轮次恰好通过所选 provider 创建一个子 agent。Spawn 给该子 agent 一个没有种子的独立会话,同时保留父级 cwd,因此共享工作树是持久权威,父对话和先前子 agent 历史都不会进入请求。 固定提示只传递不可变目标、当前轮次与上限、以工作区为权威的指令,以及上一份结构化报告。`RalphRoundReport` 包含 `status: continue | complete | blocked`、`summary`、`evidence`、`nextSteps` 和 `blocker`。字符串必须规范化;`continue` 要求存在下一步且没有阻塞项,`complete` 要求存在证据且没有下一步或阻塞项,`blocked` 要求具体阻塞项。报告成为下一次交接前,脚本会验证语义与序列化大小;消费者还会跨工作流接缝再次验证实体化的终止值。 -`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 默认为 `16384`。两者都是正安全整数配置值;过大的报告会失败,而不会被静默截断。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。 +`maxRounds` 默认为 `256`,同时也是调用覆盖值的部署上限。`maxHandoffChars` 和 `maxResultChars` 均默认为 `16384`。三者都是正安全整数配置值。过大的交接会失败,而不会被静默截断;`maxResultChars` 单独限制面向父级的完整成功文本,包括外层文本和截断标记,并且不会改变跨轮状态。最后一个允许轮次报告 `continue` 后,固定脚本返回 `budget-limited`;`complete` 和 `blocked` 会立即返回最终报告与已启动轮次数。 + +工作流语言会把正常结束但未成功的子 agent 映射为 `null`。固定脚本会在报告验证前检测该值,并返回 `round-failed`,其中包含失败轮次,以及存在时的上一份成功交接;工具会把它转成错误,而不会误判为畸形报告或预算耗尽。Ralph 不添加重试策略。致命的 provider 启动、传输、工作线程和工作流错误仍是通用工作流失败,因为这些路径上的工作流接缝不携带可恢复的子报告。 ### 模型与 UI 表面 模型只能提供 `objective` 和可选的 `maxRounds`;provider 选择、报告 schema、交接上限和脚本都由部署拥有。固定提示区段说明,只有直接人类明确要求 Ralph 或全新 agent 迭代时才使用 `ralph`,并将其与同会话目标、有界委派和通用扇出工作流区分开。这是指导,而不是新的目标 UX 状态机。 -ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。父转录只保留原始工具调用和一份有界终止报告,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 +ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入。成功完成与阻塞的外层文本会说明结果由工作者报告,而不会把它呈现为独立认证。父转录只保留原始工具调用,以及一份有界成功终止报告或一个错误,不包含中间子 agent 消息。发布的无头、REPL/TUI 与 ACP 组合会在现有工作流引擎旁加载该插件;JSON-RPC 保持不变,因为其默认组合不暴露工作流。 ## 测试 -单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由、三种终止结果、畸形及过大边界值、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明,每次运行的 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 +单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 -一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、终止完成以及两个子 agent 都被处置。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 +一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 ## 考虑过的替代方案 @@ -56,7 +62,7 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入 - 目标轮次与 Ralph 轮次保持不同概念:前者是一次同会话续行回合,后者是前台工作流中的一个全新子 agent。 - 工作区成为权威跨轮记忆,因此工作者必须检查和验证工作区,而不能信任叙事性交接。 - 宽裕的轮次上限允许大量自治工作,而部署配置仍会限制子 agent 数量,并且每次交接始终受大小约束。 -- provider 路由成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 +- provider 路由与可降低的每次运行子 agent 上限成为显式的工作流启动关注点,但不扩展脚本或普通工作流工具表面。 ## 已知限制与推迟工作 @@ -64,4 +70,5 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入 - 运行位于前台且只存在于进程内。后台收集、持久化/恢复、调度和重启恢复均不存在。 - 轮次数是唯一聚合预算。token、货币、耗时和 provider 用量预算仍属于未来的独立策略。 - 每轮创建一个子 agent。轮内扇出、evaluator/工作者角色分离、动态 provider 或模型选择,以及跨运行日志均被推迟。 +- 普通子 agent 失败会结束运行且不重试,同时保留失败轮次与上一份成功交接。致命工作流基础设施错误可能在固定脚本返回该状态前结束;增加重试或更丰富的失败传输需要独立的策略与接缝设计。 - 提示指导模型不要递归调用 Ralph;结构化的子 agent 工具限制需要另行设计工作流子策略表面。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 58679ef712..3d83bc82e1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1146,6 +1146,8 @@ export interface Config { maxRounds?: number /** Maximum serialized characters in one structured handoff (default 16384). */ maxHandoffChars?: number + /** Maximum characters in a successful parent-facing terminal text (default 16384). */ + maxResultChars?: number } ``` diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index cf322251f0..8d8e47fc79 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -8,7 +8,7 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` for the run, but the script cannot observe or replace it. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv /** @@ -32,6 +32,11 @@ interface WorkflowStartRequest { * provider. */ subagentProvider?: string + /** + * Optional per-run total-child ceiling. Implementations reject values above + * their deployment ceiling before publishing the run. + */ + maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 29a026008d..45dcd5821c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -487,7 +487,7 @@ create, edit, pause, and resume require direct-human root authority; complete an ### `ralph` -Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. +Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index d6a5d0fa11..b3950c7e88 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. ## Writing code for run_code @@ -74,7 +74,7 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index aa4f949726..9881086bcb 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -132,7 +132,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index bbed43d13b..da61a4bf95 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. ## Writing code for run_code @@ -57,7 +57,7 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 44f111c9d4..d8a06f0797 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -75,7 +75,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index bbed43d13b..da61a4bf95 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. ## Writing code for run_code @@ -57,7 +57,7 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 860803d699..6e6abbc469 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -22,7 +22,7 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. ## Writing code for run_code @@ -74,7 +74,7 @@ declare const tools: { }): Promise; /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools. */ + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph(args: { /** The immutable completion objective for every fresh Ralph round. */ objective: string; diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index 751040c92e..04e048966b 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -16,7 +16,7 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. @@ -38,4 +38,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 2680ce4d84..cfe5cc4712 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -75,7 +75,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { @@ -439,7 +439,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 472db36968..d8e7c97143 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -15,7 +15,7 @@ Use goal tools for one long-running completion objective in the current session. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. @@ -37,4 +37,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 2680ce4d84..cfe5cc4712 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -75,7 +75,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { @@ -439,7 +439,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 6bc89dccd5..433ef34d3c 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -16,4 +16,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 12e0c50907..7684660b24 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -75,7 +75,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 6bc89dccd5..433ef34d3c 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -16,4 +16,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 12e0c50907..7684660b24 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -75,7 +75,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index cc8fc47873..80c1783dd0 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -22,4 +22,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index f89f296f06..f4bcfeda2d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -105,7 +105,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md index dc934714b9..b44197f884 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md @@ -22,4 +22,4 @@ Approval prompts are disabled in this session: actions that require approval are Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json index f89f296f06..f4bcfeda2d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json @@ -105,7 +105,7 @@ }, { "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns on completion, a concrete blocker, or the round limit. Ordinary long-running same-session work belongs to goal tools.", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", "parameters": { "type": "object", "properties": { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f4a96176ee..fedd354d3c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1791,7 +1791,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkflowStartRequest', - declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n parent: Agent;\n signal?: AbortSignal;\n}', + declaration: 'export interface WorkflowStartRequest {\n script: string;\n meta: WorkflowMeta;\n args?: unknown;\n subagentProvider?: string;\n maxTotalAgents?: number;\n parent: Agent;\n signal?: AbortSignal;\n}', }, { name: 'WorkflowStopReason', diff --git a/packages/workflow/tool-ralph/README.md b/packages/workflow/tool-ralph/README.md index b264f14a16..230e2db643 100644 --- a/packages/workflow/tool-ralph/README.md +++ b/packages/workflow/tool-ralph/README.md @@ -4,11 +4,13 @@ The model-facing `ralph` tool runs a fixed foreground workflow that gives one im ## Contract -`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. +`ralph({ objective, maxRounds? })` waits for the entire run. The deployment config's `maxRounds` is both the default and a ceiling on a call override. Every Ralph round starts one child through `subagentProvider`; that provider must exist, support structured output, and report `inheritsParentContext: false`. The configured provider is carried as `WorkflowStartRequest.subagentProvider`, so the fixed script cannot inspect or change routing and the ordinary model-written `workflow` tool gains no provider selector. The resolved round cap is also carried as `WorkflowStartRequest.maxTotalAgents`, coordinating the fixed loop with the engine's total-child backstop; the engine rejects a Ralph cap above its deployment ceiling before publishing a run. Each child receives only the immutable objective, its current Ralph round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded. Reports have `status: continue | complete | blocked`, a non-empty summary, evidence, next steps, and blocker text. Status-specific semantics and the serialized `maxHandoffChars` ceiling are validated inside the fixed workflow and again at the consumer boundary. Invalid, missing, or oversized reports fail the workflow instead of being truncated or mistaken for cap exhaustion. -The terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Child self-declaration determines completion in this cut. A workflow failure or cancellation is an error result; partial output is never success. +The successful terminal tool result is `complete`, `blocked`, or `budget-limited`, with the last bounded report and number of rounds started. Completion and blocker labels explicitly say that a worker reported the outcome; they are not independent certification. `maxResultChars` bounds the complete successful text including its envelope and truncation marker, without altering the validated report used as a cross-round handoff. + +An ordinary child failure produces an error naming the failed round and retaining the last successful handoff when one exists. Ralph does not retry that round. Fatal provider-start, transport, worker, or workflow failures remain workflow errors and may settle before the fixed script can return a handoff. Cancellation is also an error; partial output is never success. ## Lifecycle and cancellation @@ -25,6 +27,7 @@ The pending call is a `generic` card titled `ralph`; the immutable objective is | `subagentProvider` | `spawn` | Fresh structured-output provider used for every round. | | `maxRounds` | `256` | Default and deployment ceiling for one Ralph run. | | `maxHandoffChars` | `16384` | Maximum serialized characters in one round report. | +| `maxResultChars` | `16384` | Maximum characters in the complete successful parent result. | All config values are normalized and validated when the plugin applies, including direct application outside Loader schema normalization. Provider capabilities are resolved immediately before each call because provider registration can change under plugin lifecycle and HMR. @@ -39,7 +42,7 @@ Every parent request in this plugin's registration scope receives the fixed rout ##### Ralph guidance ```markdown -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. ``` #### Token effect @@ -68,11 +71,11 @@ Prefix-stable while the definition and visibility are unchanged. #### What the model sees -Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. +Each child sees the standalone fixed round prompt plus the structured-output capture contract. The parent sees only the original call and one terminal result containing a worker-reported status, round count, and pretty-printed final report; intermediate child messages and reports do not enter the parent conversation. A failed ordinary child instead yields an error with its round number and, after round one, the last successful handoff. #### Token effect -Every round pays for a fresh child context. The parent result is bounded indirectly by `maxHandoffChars`; child work remains outside the parent context. +Every round pays for a fresh child context. `maxHandoffChars` bounds cross-round state and `maxResultChars` independently bounds the complete successful parent text; child work remains outside the parent context. #### KV Cache effect @@ -84,4 +87,5 @@ Each fresh child has an independent request cache. The parent result appends aft - **Foreground only** — there is no task id, background collection, process-resume checkpoint, scheduler, or wall-clock start policy. - **The workspace is the only cross-round long-term memory** — one bounded report is the explicit handoff, and uncommitted conversational reasoning disappears with each child. - **One round is one fresh child** — there is no within-round fan-out, model/provider switching, fork context, or model-call-selected provider. +- **Ordinary child failure is terminal for the run** — the fixed script reports the failed round and last successful handoff but does not retry; fatal workflow infrastructure failures can end before that state is returned. - **Only round count bounds aggregate effort** — token, price, and elapsed-time budgets are deferred. diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index 83da19b119..0f23ae062a 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -26,6 +26,8 @@ export interface Config { maxRounds?: number /** Maximum serialized characters in one structured handoff (default 16384). */ maxHandoffChars?: number + /** Maximum characters in a successful parent-facing terminal text (default 16384). */ + maxResultChars?: number } /** Schemastery configuration for the Ralph tool. */ @@ -33,12 +35,14 @@ export const Config: z = z.object({ subagentProvider: z.string().default('spawn'), maxRounds: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(256), maxHandoffChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384), + maxResultChars: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(16_384), }) interface ResolvedConfig { readonly subagentProvider: string readonly maxRounds: number readonly maxHandoffChars: number + readonly maxResultChars: number } type RalphRoundStatus = 'continue' | 'complete' | 'blocked' @@ -59,6 +63,14 @@ interface RalphRunResult { readonly report: RalphRoundReport } +interface RalphRoundFailure { + readonly status: 'round-failed' + readonly roundsStarted: number + readonly lastReport?: RalphRoundReport +} + +type RalphTerminalResult = RalphRunResult | RalphRoundFailure + interface RalphCallArgs { objective: string maxRounds?: number @@ -136,8 +148,8 @@ function validateReport(report) { } let previous +phase('Fresh-agent rounds') for (let round = 1; round <= args.maxRounds; round += 1) { - phase('Fresh-agent rounds') const prior = previous === undefined ? '(none — this is the first round)' : JSON.stringify(previous) const prompt = [ 'You are one fresh worker in a foreground Ralph loop. You receive no parent conversation and no prior child session. Do not call the ralph tool: this round already is its worker.', @@ -147,11 +159,15 @@ for (let round = 1; round <= args.maxRounds; round += 1) { 'Previous structured handoff:\n' + prior, 'Return one report with exact normalized strings. Use status continue with at least one nextSteps entry while useful work remains; complete only with concrete evidence and no nextSteps; blocked only when no meaningful progress is possible without human input or an external-state change. blocker must be empty unless blocked.', ].join('\n\n') - const report = validateReport(await agent(prompt, { + const rawReport = await agent(prompt, { label: 'Ralph round ' + round, phase: 'Fresh-agent rounds', schema: reportSchema, - })) + }) + if (rawReport === null) { + return { status: 'round-failed', roundsStarted: round, lastReport: previous ?? null } + } + const report = validateReport(rawReport) if (report.status === 'complete') return { status: 'complete', roundsStarted: round, report } if (report.status === 'blocked') return { status: 'blocked', roundsStarted: round, report } previous = report @@ -162,8 +178,8 @@ return { status: 'budget-limited', roundsStarted: args.maxRounds, report: previo const DESCRIPTION = 'Run a foreground fresh-agent Ralph loop toward one immutable objective. ' + 'Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round ' + 'opens a new child with no parent conversation or prior child session; the shared workspace is ' - + 'long-term memory, and only a bounded structured report crosses rounds. The call returns on ' - + 'completion, a concrete blocker, or the round limit. Ordinary long-running same-session work ' + + 'long-term memory, and only a bounded structured report crosses rounds. The call returns when ' + + 'a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work ' + 'belongs to goal tools.' /** Validate defaults even when a caller invokes apply() without Loader normalization. */ @@ -171,6 +187,7 @@ function resolveConfig(config: Config): ResolvedConfig { const subagentProvider = config.subagentProvider ?? 'spawn' const maxRounds = config.maxRounds ?? 256 const maxHandoffChars = config.maxHandoffChars ?? 16_384 + const maxResultChars = config.maxResultChars ?? 16_384 if (subagentProvider.length === 0 || subagentProvider !== subagentProvider.trim()) { throw new TypeError('subagentProvider must be a non-empty normalized string') } @@ -180,7 +197,10 @@ function resolveConfig(config: Config): ResolvedConfig { if (!Number.isSafeInteger(maxHandoffChars) || maxHandoffChars < 1) { throw new TypeError('maxHandoffChars must be a positive safe integer') } - return { subagentProvider, maxRounds, maxHandoffChars } + if (!Number.isSafeInteger(maxResultChars) || maxResultChars < 1) { + throw new TypeError('maxResultChars must be a positive safe integer') + } + return { subagentProvider, maxRounds, maxHandoffChars, maxResultChars } } /** Resolve one model-selected cap against the deployment ceiling. */ @@ -259,9 +279,8 @@ function readReport(value: unknown, expectedStatus: RalphRoundStatus, maxChars: } /** Defensively decode the fixed script's terminal value. */ -function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphRunResult { +function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: number): RalphTerminalResult { if (!isRecord(value) - || Object.keys(value).sort().join(',') !== 'report,roundsStarted,status' || typeof value['roundsStarted'] !== 'number' || !Number.isSafeInteger(value['roundsStarted']) || value['roundsStarted'] < 1 @@ -271,14 +290,42 @@ function readRunResult(value: unknown, maxRounds: number, maxHandoffChars: numbe const roundsStarted = value['roundsStarted'] switch (value['status']) { case 'complete': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } return { status: 'complete', roundsStarted, report: readReport(value['report'], 'complete', maxHandoffChars) } case 'blocked': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } return { status: 'blocked', roundsStarted, report: readReport(value['report'], 'blocked', maxHandoffChars) } case 'budget-limited': + if (Object.keys(value).sort().join(',') !== 'report,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } if (roundsStarted !== maxRounds) { throw new Error('Ralph workflow returned budget-limited before the round limit') } return { status: 'budget-limited', roundsStarted, report: readReport(value['report'], 'continue', maxHandoffChars) } + case 'round-failed': { + if (Object.keys(value).sort().join(',') !== 'lastReport,roundsStarted,status') { + throw new Error('Ralph workflow returned a malformed terminal result') + } + if (roundsStarted === 1) { + if (value['lastReport'] !== null) { + throw new Error('Ralph workflow returned an invalid first-round failure') + } + return { status: 'round-failed', roundsStarted } + } + if (value['lastReport'] === null) { + throw new Error('Ralph workflow returned a round failure without its last handoff') + } + return { + status: 'round-failed', + roundsStarted, + lastReport: readReport(value['lastReport'], 'continue', maxHandoffChars), + } + } default: throw new Error('Ralph workflow returned an unknown terminal status') } @@ -300,17 +347,40 @@ function stopReasonError(result: WorkflowResult): string | undefined { } } -/** Render the fixed terminal envelope without dropping the bounded report. */ -function renderResult(result: RalphRunResult): string { +const TRUNCATION_NOTICE = '\n… [truncated]' + +/** Bound complete parent-facing text, including its envelope and truncation marker. */ +function boundResult(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + if (maxChars <= TRUNCATION_NOTICE.length) return TRUNCATION_NOTICE.slice(0, maxChars) + return `${text.slice(0, maxChars - TRUNCATION_NOTICE.length)}${TRUNCATION_NOTICE}` +} + +/** Render the fixed terminal envelope without presenting self-report as certification. */ +function renderResult(result: RalphRunResult, maxChars: number): string { const rounds = `${result.roundsStarted} round${result.roundsStarted === 1 ? '' : 's'}` + let text: string switch (result.status) { case 'complete': - return `Ralph completed after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + text = `Ralph worker reported completion after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break case 'blocked': - return `Ralph blocked after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + text = `Ralph worker reported a blocker after ${rounds}.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break case 'budget-limited': - return `Ralph reached its ${rounds} limit with work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + text = `Ralph reached its ${rounds} limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify(result.report, null, 2)}` + break } + return boundResult(text, maxChars) +} + +/** Render an ordinary child failure with the most recent durable handoff. */ +function renderRoundFailure(result: RalphRoundFailure, maxChars: number): string { + const header = `Ralph round ${result.roundsStarted} child failed before producing a structured report.` + const text = result.lastReport === undefined + ? `${header}\nNo previous handoff was available.` + : `${header}\nLast successful handoff:\n${JSON.stringify(result.lastReport, null, 2)}` + return boundResult(text, maxChars) } function presentCall(args: RalphCallArgs): ToolCallView { @@ -329,7 +399,7 @@ export function apply(ctx: Context, config: Config): void { ctx.systemPrompt.section({ name: 'tool:ralph', order: 116, - text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', + text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', }) ctx.tools.register(defineTool({ name: 'ralph', @@ -360,6 +430,7 @@ export function apply(ctx: Context, config: Config): void { meta: RALPH_META, args: { objective, maxRounds, maxHandoffChars: resolved.maxHandoffChars }, subagentProvider: resolved.subagentProvider, + maxTotalAgents: maxRounds, parent, ...exec.signal === undefined ? {} : { signal: exec.signal }, }) @@ -372,7 +443,8 @@ export function apply(ctx: Context, config: Config): void { const error = stopReasonError(settled) if (error !== undefined) throw new Error(error) const value = readRunResult(settled.value, maxRounds, resolved.maxHandoffChars) - return [{ type: 'text', text: renderResult(value) }] + if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars)) + return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }] } finally { exec.signal?.removeEventListener('abort', onAbort) await run.dispose() diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index f984e67d22..836f023b41 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -10,9 +10,31 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' import * as spawn from '@deepseek-ai/dsh-subagent-spawn' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as toolRalph from '../src/index.ts' +type MockScript = ConstructorParameters[0] + +/** Mount the shipped Ralph execution stack around one keyless model script. */ +async function mountRalph(script: MockScript, config: toolRalph.Config) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + await ctx.plugin(WorkerWorkflowEngine, {}) + await ctx.plugin(toolRalph, config) + ctx.llm.registerAdapter(['mock'], adapter) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('ralph-parent'), + meta: { cwd: '/tmp/ralph-shared-workspace' }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + return { ctx, adapter, parentHandle, parent: parentHandle.agent } +} + describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', async () => { const firstReport = { @@ -54,6 +76,8 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parent.whenIdle() const children: Agent[] = [] + const phases: string[] = [] + ctx.on('workflow/phase', (_run, title) => { phases.push(title) }) ctx.on('workflow/agent-start', (_run, child) => { const agent = ctx.agents.get(child.childId) expect(agent).toBeDefined() @@ -67,7 +91,9 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { }) expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 2 rounds.') + expect((result.content[0] as { text: string }).text) + .toContain('Ralph worker reported completion after 2 rounds.') + expect(phases).toEqual(['Fresh-agent rounds']) expect(children).toHaveLength(2) expect(new Set(children.map(child => child.id)).size).toBe(2) for (const child of children) { @@ -89,4 +115,151 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) + + it('reports the failed round and last good handoff when a child fails', async () => { + const firstReport = { + status: 'continue', + summary: 'ROUND_ONE_HANDOFF', + evidence: ['Created migration-a.ts.'], + nextSteps: ['Finish migration-b.ts.'], + blocker: '', + } + const { ctx, parent, parentHandle } = await mountRalph([ + toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport), + maxTokensResponse('unfinished child output'), + ], { maxRounds: 2 }) + const children: Agent[] = [] + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + if (agent !== undefined) children.push(agent) + }) + + const result = await ctx.tools.execute({ + callId: CallId('ralph-child-failure'), + name: 'ralph', + arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, + agent: parent, + }) + + expect(result.isError).toBe(true) + const text = (result.content[0] as { text: string }).text + expect(text).toContain('Ralph round 2 child failed before producing a structured report.') + expect(text).toContain('Last successful handoff:') + expect(text).toContain('ROUND_ONE_HANDOFF') + expect(children).toHaveLength(2) + for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined() + await parentHandle.dispose() + }) + + it.each([ + { + name: 'blocked', + report: { + status: 'blocked', + summary: 'External authorization is required.', + evidence: ['The local implementation is ready.'], + nextSteps: ['Continue after authorization.'], + blocker: 'The required external authorization is unavailable.', + }, + config: { maxRounds: 2 }, + expectedError: false, + expectedText: 'Ralph worker reported a blocker after 1 round.', + }, + { + name: 'budget-limited', + report: { + status: 'continue', + summary: 'One slice is complete.', + evidence: ['The first focused test passes.'], + nextSteps: ['Implement the remaining slice.'], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: false, + expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.', + }, + { + name: 'unnormalized report', + report: { + status: 'continue', + summary: ' padded summary ', + evidence: ['A focused test passes.'], + nextSteps: ['Continue implementation.'], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: true, + expectedText: 'summary must be non-empty and normalized', + }, + { + name: 'invalid continuing report', + report: { + status: 'continue', + summary: 'Work remains.', + evidence: ['A focused test passes.'], + nextSteps: [], + blocker: '', + }, + config: { maxRounds: 1 }, + expectedError: true, + expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker', + }, + { + name: 'oversized report', + report: { + status: 'continue', + summary: 'x'.repeat(300), + evidence: ['A focused test passes.'], + nextSteps: ['Continue implementation.'], + blocker: '', + }, + config: { maxRounds: 1, maxHandoffChars: 100 }, + expectedError: true, + expectedText: 'Ralph round report exceeds maxHandoffChars', + }, + ])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => { + const { ctx, parent, parentHandle } = await mountRalph([ + toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report), + ], config) + + const result = await ctx.tools.execute({ + callId: CallId('ralph-script-enforcement'), + name: 'ralph', + arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds }, + agent: parent, + }) + + expect(result.isError).toBe(expectedError) + expect((result.content[0] as { text: string }).text).toContain(expectedText) + await parentHandle.dispose() + }) + + it('cancels the real worker and fresh child to quiescence', async () => { + const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 }) + const children: Agent[] = [] + const outcomes: string[] = [] + ctx.on('workflow/agent-start', (_run, child) => { + const agent = ctx.agents.get(child.childId) + if (agent !== undefined) children.push(agent) + }) + ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('ralph-real-cancel'), + name: 'ralph', + arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 }, + agent: parent, + signal: controller.signal, + }) + await vi.waitFor(() => { expect(children).toHaveLength(1) }) + + controller.abort() + const result = await pending + + expect(result.isError).toBe(true) + expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled') + expect(outcomes).toEqual(['cancelled']) + expect(ctx.agents.get(children[0]!.id)).toBeUndefined() + await parentHandle.dispose() + }) }) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index b7a70fd7d7..119da3def3 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -82,6 +82,7 @@ async function setup(options?: SetupOptions) { if (options?.config?.subagentProvider !== undefined) config.subagentProvider = options.config.subagentProvider if (options?.config?.maxRounds !== undefined) config.maxRounds = options.config.maxRounds if (options?.config?.maxHandoffChars !== undefined) config.maxHandoffChars = options.config.maxHandoffChars + if (options?.config?.maxResultChars !== undefined) config.maxResultChars = options.config.maxResultChars const fiber = await ctx.plugin(toolRalph, config) const parent = { id: SessionId('caller'), options: {} } as unknown as Agent return { ctx, engine: ctx.workflows as StubEngine, parent, fiber } @@ -145,6 +146,7 @@ describe('dsh-tool-ralph', () => { meta: { name: 'ralph-loop' }, args: { objective: 'Finish the migration.', maxRounds: 4, maxHandoffChars: 9000 }, subagentProvider: 'fresh', + maxTotalAgents: 4, parent, }) expect(engine.requests[0]!.script).toContain("status: 'budget-limited'") @@ -154,7 +156,8 @@ describe('dsh-tool-ralph', () => { report: COMPLETE, }) expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toContain('Ralph completed after 1 round.') + expect((result.content[0] as { text: string }).text) + .toContain('Ralph worker reported completion after 1 round.') expect((result.content[0] as { text: string }).text).toContain('All required gates pass.') expect(engine.disposed).toBe(1) }) @@ -167,7 +170,8 @@ describe('dsh-tool-ralph', () => { roundsStarted: 2, report: BLOCKED, }, 2) - expect((blockedResult.content[0] as { text: string }).text).toContain('Ralph blocked after 2 rounds.') + expect((blockedResult.content[0] as { text: string }).text) + .toContain('Ralph worker reported a blocker after 2 rounds.') const limited = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) @@ -177,7 +181,54 @@ describe('dsh-tool-ralph', () => { report: CONTINUE, }, 2) expect((limitedResult.content[0] as { text: string }).text) - .toContain('Ralph reached its 2 rounds limit with work remaining.') + .toContain('Ralph reached its 2 rounds limit; the worker reported work remaining.') + }) + + it('bounds the complete parent result and labels worker-reported completion', async () => { + const { ctx, engine, parent } = await setup({ config: { maxResultChars: 160 } }) + const pending = execute(ctx, { objective: 'Ship it.' }, { agent: parent }) + const result = await settleCompleted(engine, pending, { + status: 'complete', + roundsStarted: 1, + report: { ...COMPLETE, evidence: ['x'.repeat(500)] }, + }) + const text = (result.content[0] as { text: string }).text + expect(text).toHaveLength(160) + expect(text).toContain('Ralph worker reported completion after 1 round.') + expect(text).toMatch(/… \[truncated\]$/) + }) + + it('honors a result limit shorter than the truncation marker', async () => { + const { ctx, engine, parent } = await setup({ config: { maxResultChars: 5 } }) + const result = await settleCompleted(engine, execute(ctx, { objective: 'Ship it.' }, { agent: parent }), { + status: 'complete', + roundsStarted: 1, + report: COMPLETE, + }) + expect((result.content[0] as { text: string }).text).toBe('\n… [t') + }) + + it('reports an ordinary child failure with the failed round and last durable handoff', async () => { + const { ctx, engine, parent } = await setup({ config: { maxRounds: 2 } }) + const first = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent }) + const firstResult = await settleCompleted(engine, first, { + status: 'round-failed', + roundsStarted: 1, + lastReport: null, + }) + expect(firstResult.isError).toBe(true) + expect((firstResult.content[0] as { text: string }).text).toContain('Ralph round 1 child failed') + expect((firstResult.content[0] as { text: string }).text).toContain('No previous handoff was available.') + + const later = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent }) + const laterResult = await settleCompleted(engine, later, { + status: 'round-failed', + roundsStarted: 2, + lastReport: CONTINUE, + }) + expect(laterResult.isError).toBe(true) + expect((laterResult.content[0] as { text: string }).text).toContain('Ralph round 2 child failed') + expect((laterResult.content[0] as { text: string }).text).toContain('Implemented the first slice.') }) it('maps workflow error and cancellation reasons to tool errors and always disposes', async () => { @@ -251,6 +302,7 @@ describe('dsh-tool-ralph', () => { expect(() => { toolRalph.apply(new Context(), { subagentProvider: ' ' }) }).toThrow('non-empty normalized') expect(() => { toolRalph.apply(new Context(), { maxRounds: 0 }) }).toThrow('positive safe integer') expect(() => { toolRalph.apply(new Context(), { maxHandoffChars: 1.5 }) }).toThrow('positive safe integer') + expect(() => { toolRalph.apply(new Context(), { maxResultChars: 0 }) }).toThrow('positive safe integer') }) it('turns malformed fixed-workflow terminal values and reports into errors', async () => { @@ -261,11 +313,18 @@ describe('dsh-tool-ralph', () => { { value: { status: 'mystery', roundsStarted: 1, report: COMPLETE }, message: 'unknown terminal status' }, { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE }, message: 'before the round limit', config: { maxRounds: 2 } }, { value: { status: 'complete', roundsStarted: 1, report: null }, message: 'malformed round report' }, + { value: { status: 'complete', roundsStarted: 1, report: COMPLETE, extra: true }, message: 'malformed terminal result' }, + { value: { status: 'blocked', roundsStarted: 1, report: BLOCKED, extra: true }, message: 'malformed terminal result' }, + { value: { status: 'budget-limited', roundsStarted: 1, report: CONTINUE, extra: true }, message: 'malformed terminal result', config: { maxRounds: 1 } }, { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, status: 'continue' } }, message: 'malformed round report' }, { value: { status: 'budget-limited', roundsStarted: 1, report: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 1 } }, { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, evidence: [] } }, message: 'invalid completion report' }, { value: { status: 'blocked', roundsStarted: 1, report: { ...BLOCKED, blocker: '' } }, message: 'invalid blocked report' }, { value: { status: 'complete', roundsStarted: 1, report: { ...COMPLETE, summary: 'x'.repeat(500) } }, message: 'oversized handoff', config: { maxHandoffChars: 100 } }, + { value: { status: 'round-failed', roundsStarted: 1 }, message: 'malformed terminal result' }, + { value: { status: 'round-failed', roundsStarted: 1, lastReport: CONTINUE }, message: 'invalid first-round failure' }, + { value: { status: 'round-failed', roundsStarted: 2, lastReport: null }, message: 'without its last handoff', config: { maxRounds: 2 } }, + { value: { status: 'round-failed', roundsStarted: 2, lastReport: { ...CONTINUE, nextSteps: [] } }, message: 'invalid continuing report', config: { maxRounds: 2 } }, ] for (const testCase of cases) { const { ctx, engine, parent } = await setup( @@ -294,7 +353,9 @@ describe('dsh-tool-ralph', () => { const { ctx, fiber } = await setup() const section = (await ctx.systemPrompt.assemble()).sections.find(candidate => candidate.name === 'tool:ralph') expect(section?.text).toContain('ONLY when the direct human explicitly asks') + expect(section?.text).toContain('worker reports, not independent evaluation') const tool = ctx.tools.get('ralph')! + expect(tool.description).toContain('worker reports completion') expect(tool.presentCall!({ objective: 'Finish it.' })).toEqual({ card: 'generic', title: 'ralph', diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 23fa849888..74eacc5fd8 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -34,7 +34,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide ## Run sequence -`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. +`start()` validates meta, parses the body, resolves a registered normalized provider route, and resolves any per-run total-child cap before creating a worker or publishing `workflow/start`. A requested `maxTotalAgents` must be a positive safe integer no greater than the engine's configured deployment ceiling. Source mode installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each `agent()` call: @@ -81,7 +81,7 @@ The host keeps a ledger of forwarded child starts. A graceful worker supplies th | `syncTimeoutMs` | `5000` | VM timeout for the script's initial synchronous slice. | | `disposeGraceMs` | `5000` | Bound before force-settlement/termination and for public disposal. | -An owning consumer may set `WorkflowStartRequest.subagentProvider` for one run. This is an engine-level route, not a script hook or a model-facing option; the ordinary `workflow` tool leaves it unset. +An owning consumer may set `WorkflowStartRequest.subagentProvider` and `WorkflowStartRequest.maxTotalAgents` for one run. These are engine-level policy, not script hooks or model-facing options; the ordinary `workflow` tool leaves both unset. A per-run total-child cap may lower but never raise the configured `maxTotalAgents` ceiling. ## Model Experience diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 604d8ba754..33c5917acf 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -73,6 +73,36 @@ function assertBodyParses(body: string, name: string): void { } } +/** Resolve one run's provider route before publishing work. */ +function resolveSubagentProvider(ctx: Context, configured: string, override: string | undefined): string { + const provider = override ?? configured + if (provider.length === 0 || provider !== provider.trim()) { + throw new WorkflowError( + 'workflow subagentProvider must be a non-empty normalized string', + 'INVALID_ARGUMENT', + ) + } + if (ctx.subagents.getProvider(provider) === undefined) { + throw new WorkflowError(`no subagent provider registered for "${provider}"`, 'AGENT_START') + } + return provider +} + +/** Resolve one run's total-child cap against the engine deployment ceiling. */ +function resolveMaxTotalAgents(requested: number | undefined, ceiling: number): number { + if (requested === undefined) return ceiling + if (!Number.isSafeInteger(requested) || requested < 1) { + throw new WorkflowError('workflow maxTotalAgents must be a positive safe integer', 'INVALID_ARGUMENT') + } + if (requested > ceiling) { + throw new WorkflowError( + `workflow maxTotalAgents ${requested} exceeds the engine ceiling ${ceiling}`, + 'INVALID_ARGUMENT', + ) + } + return requested +} + /** * The worker-thread engine service. `start()` validates the script up front * (meta + a host-side body parse) and returns a {@link WorkflowRun} whose @@ -113,13 +143,15 @@ class WorkerWorkflowEngine extends WorkflowService { start(request: WorkflowStartRequest): WorkflowRun { const meta = validateMeta(request.meta) assertBodyParses(request.script, meta.name) + const subagentProvider = resolveSubagentProvider(this.ctx, this.config.provider, request.subagentProvider) + const maxTotalAgents = resolveMaxTotalAgents(request.maxTotalAgents, this.config.maxTotalAgents) const id = WorkflowRunId(randomUUID()) const info: WorkflowRunInfo = { id, meta } const limits: WorkerLimits = { maxConcurrentAgents: this.config.maxConcurrentAgents === 0 ? Math.min(16, Math.max(1, availableParallelism() - 2)) : this.config.maxConcurrentAgents, - maxTotalAgents: this.config.maxTotalAgents, + maxTotalAgents, maxItemsPerCall: this.config.maxItemsPerCall, syncTimeoutMs: this.config.syncTimeoutMs, } @@ -137,7 +169,6 @@ class WorkerWorkflowEngine extends WorkflowService { // the now-inactive engine fiber and break the seam's holder-owned lifetime. const runCtx = this.ctx const subagents = runCtx.subagents - const subagentProvider = request.subagentProvider ?? this.config.provider const workerRun = new WorkerRun( runCtx, subagents, diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index d82eae699d..535917fc42 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -255,7 +255,7 @@ export class WorkflowExecution { const opts = this.readAgentOptions(rawOpts) if (this.started >= this.limits.maxTotalAgents) { throw new WorkflowError( - `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`, + `this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, 'AGENT_CAP', ) } diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index 102bcbd9a0..b856a48785 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -392,6 +392,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { const result = await host.result() expect(result.stopReason).toBe('error') expect(result.error).toContain('total agent cap (2)') + expect(result.error).toContain('applicable maxTotalAgents limit') expect(result.agentsStarted).toBe(2) host.close() }) diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 673a43ee0a..3fb08bb5ba 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -8,6 +8,7 @@ import { expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,6 +19,13 @@ vi.setConfig({ testTimeout: 30_000 }) it('runs the default config through the source worker', async () => { const ctx = new Context() const subagents = await ctx.plugin(SubagentService) + const provider: SubagentProvider = { + name: 'spawn', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: () => Promise.reject(new Error('source-worker compat script must not start a child')), + } + ctx.subagents.registerProvider(provider) const engine = await ctx.plugin(WorkerWorkflowEngine, {}) const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent try { diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index e2cc227704..d8d6f1e09d 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -6,7 +6,7 @@ import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' @@ -252,6 +252,77 @@ describe('dsh-workflow-workerthread', () => { expect(provider.runs).toHaveLength(1) }) + it('rejects invalid start-request provider routes before publishing a run', async () => { + const { ctx, parent } = await setup() + let starts = 0 + ctx.on('workflow/start', () => { starts += 1 }) + const messages: string[] = [] + for (const subagentProvider of ['', 'missing']) { + let run: WorkflowRun | undefined + let thrown: unknown + try { + run = ctx.workflows.start({ + ...scripted("return 'must not start'"), + parent, + subagentProvider, + }) + } catch (error: unknown) { + thrown = error + } + await run?.dispose() + messages.push(thrown instanceof Error ? thrown.message : '') + } + + expect(messages).toEqual([ + 'workflow subagentProvider must be a non-empty normalized string', + 'no subagent provider registered for "missing"', + ]) + expect(starts).toBe(0) + }) + + it('rejects invalid per-run total-agent caps before publishing a run', async () => { + const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } }) + let starts = 0 + ctx.on('workflow/start', () => { starts += 1 }) + const errors: unknown[] = [] + for (const maxTotalAgents of [0, 1.5, Number.NaN, 3]) { + try { + const handle = ctx.workflows.start({ + ...scripted("return 'must not start'"), + parent, + maxTotalAgents, + }) + await handle.dispose() + } catch (error: unknown) { + errors.push(error) + } + } + + expect(errors.slice(0, 3)).toEqual(Array(3).fill(expect.objectContaining({ + code: 'INVALID_ARGUMENT', + message: 'workflow maxTotalAgents must be a positive safe integer', + }))) + expect(errors[3]).toMatchObject({ + code: 'INVALID_ARGUMENT', + message: 'workflow maxTotalAgents 3 exceeds the engine ceiling 2', + }) + expect(starts).toBe(0) + }) + + it('enforces a per-run total-agent cap below the engine ceiling', async () => { + const { ctx, parent } = await setup({ config: { maxTotalAgents: 2 } }) + const handle = ctx.workflows.start({ + ...scripted("await agent('first'); await agent('second'); return 'unreachable'"), + parent, + maxTotalAgents: 1, + }) + const result = await handle.result + expect(result.stopReason).toBe('error') + expect(result.agentsStarted).toBe(1) + expect(result.error).toContain('total agent cap (1)') + await handle.dispose() + }) + it('a fatal hook error inside the worker kills the script and reports the error', async () => { const { ctx, parent } = await setup() const result = await run(ctx, parent, scripted("return await parallel([() => agent('x', { isolation: 'worktree' })])")) @@ -259,11 +330,18 @@ describe('dsh-workflow-workerthread', () => { expect(result.error).toContain('"isolation" is deferred') }) - it('a provider start failure crosses back as a fatal AGENT_START error', async () => { + it('rejects an unregistered configured provider before publishing a run', async () => { const { ctx, parent } = await setup({ config: { provider: 'nonexistent' } }) - const result = await run(ctx, parent, scripted("return await pipeline([1], () => agent('p'))")) - expect(result.stopReason).toBe('error') - expect(result.error).toContain('agent() could not start a child') + let thrown: unknown + try { + ctx.workflows.start({ ...scripted("return 'must not start'"), parent }) + } catch (error: unknown) { + thrown = error + } + expect(thrown).toMatchObject({ + code: 'AGENT_START', + message: 'no subagent provider registered for "nonexistent"', + }) }) it('waits for async provider start before announcing a result that settled early', async () => { diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 431080a555..9283f22cc7 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -6,11 +6,11 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip ## Service and run contract -`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block or unparseable script before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. +`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. A run is holder-owned. Engine-plugin unload prevents new starts but does not revoke accepted runs. The holder must call `dispose()` on every path; disposal cancels remaining work and reaches or abandons quiescence within the documented bound. -`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `meta` and `args` are plain data, not script fragments. +`WorkflowStartRequest` contains `{ meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }`. `parent` attributes every child agent to the invoking agent. `subagentProvider` optionally routes every child in that run without exposing provider choice to the script; omission uses the engine's configured provider. `maxTotalAgents` optionally lowers the engine's deployment ceiling for one run and is likewise invisible to the script. An implementation rejects invalid routes and limits synchronously. `meta` and `args` are plain data, not script fragments. `WorkflowRun` exposes `{ id, meta, result, cancel(reason?), dispose() }`. `WorkflowResult` contains `{ value, stopReason, error?, agentsStarted }`; `value` is plain JSON data or `null`. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index de6af8f838..12386659bc 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -76,6 +76,11 @@ export interface WorkflowStartRequest { * provider. */ subagentProvider?: string + /** + * Optional per-run total-child ceiling. Implementations reject values above + * their deployment ceiling before publishing the run. + */ + maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent /** Cancels the run when aborted (the tool's `exec.signal`). */ From b2f08eac4f46a47755b7f69e08330b66750aef9f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:01:07 +0800 Subject: [PATCH 068/132] test(workflow): register real-engine route --- packages/workflow/tool-workflow/tests/tool-workflow.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 08bc8171c3..d8c72ed567 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -230,6 +230,12 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spawn', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: () => Promise.reject(new Error('the parked-script fixture must not start a child')), + }) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) const parent = { id: SessionId('caller'), options: {} } as unknown as Agent From b8af6c4b430953d23adf5669ad5bc2cbbffb7176 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:13:11 +0800 Subject: [PATCH 069/132] docs(goal): align rollup with implemented stack --- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../feature/2026-07-16-harness-level-loop.md | 13 ++++++++----- .../feature/2026-07-16-harness-level-loop.zh.md | 13 ++++++++----- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 9f519419e7..9501b344dd 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 76b4efbe42bd0938fcaff14e96ad4e73883e602d -2026-07-16-harness-level-loop.zh.md: 443501cfaf8a37a210786d4b251e58116c508ba0 +2026-07-16-harness-level-loop.md: 5ed9a08f3b80fe3ff8d87d90eed8c8af34179f95 +2026-07-16-harness-level-loop.zh.md: 7608f42517dad901a48e1bb1c1d1aa57f93c0374 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index 76b4efbe42..5ed9a08f3b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -66,7 +66,7 @@ Normal turn completion schedules another round only while the goal remains activ ### Human and model surfaces -The human UX follows the compact current [Codex `/goal` command shape](https://learn.chatgpt.com/docs/developer-commands?surface=cli): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them. +The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): `/goal` shows status, `/goal ` creates, and `edit`, `pause`, `resume`, or `clear` perform direct lifecycle actions. The commit permalink keeps the researched grammar verifiable as Codex evolves. Status includes durable phase, admitted/capped rounds, and live armed/disarmed activation. Direct status and command output do not enter model history; accepted domain mutations remain reconstructable because the goal service records them. The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. @@ -76,11 +76,13 @@ TUI and ACP mount the shared command registry and complete goal stack by default Ralph is a first-class model tool in its own plugin, demonstrating that a sophisticated fixed execution policy can be composed without a new loop core. The plugin owns a fixed workflow script over `ctx.workflows` and `ctx.subagents`; it does not create session-goal state or add a branch to `dsh-agent-loop`. -Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report. +Each round uses an explicit `WorkflowStartRequest.subagentProvider`, defaulting to `spawn`. The provider must exist, support structured output, and declare that it does not inherit parent context. Ralph also passes its resolved round cap as `WorkflowStartRequest.maxTotalAgents`; the worker engine validates both per-run policies before publishing work, so provider misconfiguration or an engine ceiling below the requested Ralph scale fails before a run exists. The child inherits cwd and lineage but receives only the immutable objective, round/cap, workspace-as-authority instruction, and previous normalized report. -A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated. +A report contains status, summary, evidence, next steps, and blocker text. Status-specific invariants and serialized size are validated inside the fixed script and again at the consumer boundary. `maxRounds` is configurable, defaults to `256`, and is the ceiling for a call override. `maxHandoffChars` defaults to `16384`; oversized reports fail rather than being silently truncated. `maxResultChars` separately defaults to `16384` and bounds the complete successful parent-facing text, including its envelope and truncation marker. -The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded terminal result; intermediate child conversations remain outside the parent transcript. +An ordinary child failure ends the run without retry. The fixed script reports the failed round and last successful handoff when one exists, and the tool returns that state as an error instead of misclassifying it as a malformed report or budget exhaustion. Fatal workflow infrastructure failures can settle before the script returns that state; richer reason transport and retry policy remain deferred. + +The tool is foreground and process-local. The parent tool call waits for the terminal result, propagates cancellation into the worker engine, and awaits `run.dispose()` so child work is quiescent before return. The model sees one call and one bounded successful terminal result or an error; completion and blocker envelopes explicitly say that a worker reported the outcome rather than presenting it as independent certification. Intermediate child conversations remain outside the parent transcript. ### External design lineage @@ -92,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s ### Verification -The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, ACP transcript isolation, fixed Ralph provider routing, distinct unseeded children, bounded handoff, terminal outcomes, and cancellation quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests. +The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, and ACP transcript isolation. Ralph's keyless real stack—worker-thread engine, spawn provider, structured-output runtime, and agent loop—covers distinct unseeded children, exact bounded handoff, completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests. ## Alternatives considered @@ -123,4 +125,5 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella - **No generic loop journal or execution-world rewind** — session replay reconstructs model-visible goal history, not prior files, processes, environment, credentials, or external side effects. Ralph treats the current workspace as authority and carries no cross-run journal. - **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. - **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. +- **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design. - **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index 443501cfaf..7608f42517 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -66,7 +66,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for ### 人类与模型表面 -人类 UX 遵循当前紧凑的 [Codex `/goal` 命令形态](https://learn.chatgpt.com/docs/developer-commands?surface=cli):`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。 +人类 UX 遵循 [OpenAI Codex 在提交 `678157a` 时的公开 TUI 分发器](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)中的紧凑形态:`/goal` 显示状态,`/goal ` 创建目标,而 `edit`、`pause`、`resume` 或 `clear` 执行直接生命周期操作。该提交永久链接让研究所得语法在 Codex 演进时仍可验证。状态包含持久阶段、已接纳/上限 Round 数以及实时已激活/未激活状态。直接状态与命令输出不会进入模型历史;已接受领域变更仍可重建,因为目标服务会记录它们。 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 @@ -76,11 +76,13 @@ TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同 Ralph 是位于自有插件中的一等模型工具,展示了复杂固定执行策略可以在没有新 loop 核心的情况下组合完成。该插件拥有构建在 `ctx.workflows` 与 `ctx.subagents` 之上的固定工作流脚本;它不会创建会话目标状态,也不会为 `dsh-agent-loop` 增加分支。 -每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 +每个 Round 都使用显式 `WorkflowStartRequest.subagentProvider`,默认为 `spawn`。该 provider 必须存在、支持结构化输出,并声明不继承父上下文。Ralph 还会把解析后的 Round 上限作为 `WorkflowStartRequest.maxTotalAgents` 传递;工作线程引擎会在发布工作前验证两项每次运行策略,因此 provider 配置错误或低于所请求 Ralph 规模的引擎上限会在运行存在前失败。子 agent 继承 cwd 与谱系,但只接收不可变目标、当前 Round/上限、以工作区为权威的指令和上一份规范化报告。 -报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。 +报告包含状态、摘要、证据、下一步与阻塞文本。固定脚本内部和消费者边界都会验证状态专用不变量与序列化大小。`maxRounds` 可配置,默认为 `256`,并作为调用覆盖值的上限。`maxHandoffChars` 默认为 `16384`;过大报告会失败,而不会被静默截断。`maxResultChars` 单独默认为 `16384`,并限制面向父级的完整成功文本,包括外层文本与截断标记。 -该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用和一份有界终止结果;中间子 agent 对话不会进入父转录。 +普通子 agent 失败会结束运行且不重试。固定脚本会报告失败 Round,并在存在时带回上一份成功交接;工具会把该状态作为错误返回,而不会误判为畸形报告或预算耗尽。致命工作流基础设施错误可能在脚本返回该状态前结算;更丰富的原因传输与重试策略均予以延期。 + +该工具位于前台且只存在于进程内。父工具调用等待终止结果,把取消传播到工作线程引擎,并等待 `run.dispose()`,因此返回前子工作已达到静止。模型只看到一次调用,以及一份有界成功终止结果或一个错误;完成与阻塞的外层文本会明确说明结果由工作者报告,而不会呈现为独立认证。中间子 agent 对话不会进入父转录。 ### 外部设计谱系 @@ -92,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 ### 验证 -六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现、ACP 转录隔离、固定 Ralph provider 路由、互不相同且无种子的子 agent、有界交接、终止结果与取消静止性。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。 +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现与 ACP 转录隔离。Ralph 的无密钥真实栈——工作线程引擎、spawn provider、结构化输出运行时与 agent loop——覆盖互不相同且无种子的子 agent、准确有界交接、完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。 ## 考虑过的替代方案 @@ -123,4 +125,5 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 - **没有通用 loop 日志或执行世界回退**——会话重放会重建模型可见目标历史,而不会恢复此前文件、进程、环境、凭据或外部副作用。Ralph 把当前工作区作为权威,并且没有跨运行日志。 - **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 - **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 +- **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。 - **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。 From da70e4704856542ab7b2fab0f51dde07e35a1743 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:15:11 +0800 Subject: [PATCH 070/132] review fix: preserve idle waiter quiescence --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 6 +- .../2026-07-17-one-send-one-turn.zh.md | 14 ++-- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 19 ++++- packages/core/agent-loop/tests/cancel.spec.ts | 76 +++++++++++++++++++ .../agent-loop/tests/interception.spec.ts | 2 +- .../core/agent-loop/tests/properties.spec.ts | 4 +- 8 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 9760e026a2..f66fa4f240 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d -2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd +2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df +2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 9534574e76..852a2f24d3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -34,6 +34,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e3cd3c9f6c..f40086ee20 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,13 +8,13 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 @@ -22,11 +22,11 @@ Status: implemented ## 曾考虑的替代方案 -**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 @@ -34,6 +34,6 @@ Status: implemented ## 后果 -普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 +依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4161cad92b..ba991311ae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Already-idle pre-start cancellation still must settle queued-work waiters. + // Pre-start cancellation settles queued-work waiters before publishing idle. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 66d4a65d69..5e8168d895 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation finds the status already idle. */ + /** Settle idle waiters before pre-running cancellation publishes idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -118,6 +118,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { + // An idle listener can enqueue and cancel replacement work before the next + // wait is installed. Consume that empty marker before parking the driver. + if (handle.isCancelled()) { + handle.clearCancel() + if (!handle.inbox.hasQueued) { + handle.settleIdle() + handle.setStatus('idle') + continue + } + } + await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break @@ -126,10 +137,10 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { - // setStatus settles running→idle; the explicit settle covers the - // already-idle pre-start path where that transition is deduplicated. - handle.setStatus('idle') + // Settle before publishing idle: the already-idle path has no status + // transition, while an idle listener can register waiters for new work. handle.settleIdle() + handle.setStatus('idle') continue } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c582b4c943..c6d84dfbf7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -194,6 +194,82 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'idle steer']) }) + it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + queueMicrotask(() => { + queueMicrotask(() => { agent.cancel('between turns') }) + }) + }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + send(agent, 'cancelled tail') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 }) + expect(userTexts(agent)).toEqual(['first', 'replacement']) + }) + + it('idle-listener cancellation settles its waiter without cancelling later work', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'cancelled replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + agent.cancel('idle listener') + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(Promise.race([ + replacementObservation, + new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)), + ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'later') + await idle + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'later']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 7142abd9d6..40981a7301 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -241,7 +241,7 @@ describe('agent/prompt-submit', () => { send(agent, 'second') await idle expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // The failed prompt owns one balanced error turn; the adjacent prompt owns + // The failed prompt forms one balanced error turn; the adjacent prompt forms // the following normal turn without an intermediate idle transition. const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 9412ffa452..85efda0e4e 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // This failure-free fixture claims every item into an independent turn. + // This failure-free fixture maps every item to an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -178,7 +178,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every item is claimed and therefore owns one FIFO-ordered turn. + // Every item forms one FIFO-ordered turn containing only that message. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) From 7d7d69e22512b10322659c247ea6a2069f974eca Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:20:51 +0800 Subject: [PATCH 071/132] review fix: cover post-cancel replacement --- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 5e8168d895..d2a27394dc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -133,7 +133,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isDisposed()) break // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs and owns the eventual idle transition. + // a replacement prompt still runs before the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c6d84dfbf7..39289779be 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -270,6 +270,31 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) + it('replacement work queued after idle-listener cancellation still runs', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementIdle: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return + send(agent, 'cancelled replacement') + agent.cancel('idle listener') + send(agent, 'surviving replacement') + replacementIdle = agent.whenIdle() + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') + await replacementIdle + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) From df1643d5e53142a874191602758b9ecbed059624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:25:16 +0800 Subject: [PATCH 072/132] fix(goal): remove unreachable terminal branch --- packages/goal/tool-goal/src/index.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 5c395898b7..cd105b3705 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -111,10 +111,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen function observeMutation( terminalTurns: WeakMap, execution: GoalToolExecution, - goal: GoalView, autonomousTerminal: boolean, ): void { - if (!autonomousTerminal || (goal.phase === 'active' && goal.activation === 'armed')) { + if (!autonomousTerminal) { terminalTurns.delete(execution.agent) return } @@ -173,7 +172,7 @@ export function apply(ctx: Context, config: Config): void { objective: args.objective, ...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds }, }) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present('Create goal', 'other', args.objective), @@ -207,7 +206,7 @@ export function apply(ctx: Context, config: Config): void { if (args.action === 'edit') { requireDirectHuman(ctx, execution) const goal = ctx.goals.edit(execution.agent, ref, replacements) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal), @@ -224,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'pause' ? ctx.goals.pause(execution.agent, ref) : ctx.goals.resume(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, false) + observeMutation(terminalTurns, execution, false) return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) } const authority = completionAuthority(ctx, execution) @@ -245,7 +244,7 @@ export function apply(ctx: Context, config: Config): void { const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) : ctx.goals.block(execution.agent, ref) - observeMutation(terminalTurns, execution, goal, authority.kind === 'goal-round') + observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( From 2530bf8aa3bc3fca2fb3a4e2ade0494e556ab1f2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 20 Jul 2026 13:59:18 +0800 Subject: [PATCH 073/132] =?UTF-8?q?fix(sandbox):=20address=20PR=20#309=20r?= =?UTF-8?q?eview=20=E2=80=94=20TOCTOU=20direction,=20denial=20metadata,=20?= =?UTF-8?q?shared=20roots,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fs-sandbox: delegate the mutation with the freshly re-canonicalized target (not the stale one), so the checked identity is the mutated identity — a symlink swapped in after resolve() can no longer escape workspace-write. - tool-fs: map a denial to an FsError carrying FS_SANDBOX_DENIED (not a plain Error), so ToolRegistry keeps the structured code on result.error for retry/observers while the message stays the shared marker. - sandbox-local: derive the Seatbelt writable set from the shared writableRoots() helper, so the profile and the fs fence cannot drift. - gen-doc-graphs: ctx.sandboxPolicy is owned by dsh-sandbox-policy and read only by the sandboxed executor/provider (the tool layers use the pure fold). - docs: bash-sandbox/bash/permission READMEs and bash.md reflect the relocated policy home and the sandbox/mode rename; drop the stale stdout.golden.jsonl. --- docs/capability-seams.md | 7 ++- docs/core-data-structures/bash.md | 2 +- .../stdout.golden.jsonl | 52 ------------------- packages/bash/bash-sandbox/README.md | 10 ++-- packages/bash/bash/README.md | 2 +- packages/fs/fs-sandbox/src/index.ts | 34 ++++++------ .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 15 +++++- packages/fs/tool-fs/src/sandbox.ts | 19 ++++--- .../sandbox/sandbox-local/src/profiles.ts | 23 +++----- packages/ui/permission/README.md | 2 +- scripts/gen-doc-graphs.ts | 6 +-- 11 files changed, 65 insertions(+), 107 deletions(-) delete mode 100644 examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9387ee35f6..54df77275c 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -60,6 +60,7 @@ flowchart LR pkg_sandbox["sandbox"] svc_sandbox["ctx.sandbox
Process-sandbox seam"] pkg_sandbox_local["sandbox-local"] + pkg_sandbox_policy["sandbox-policy"] svc_sandboxPolicy["ctx.sandboxPolicy
Sandbox policy home"] pkg_fs_sandbox["fs-sandbox"] pkg_approval["approval"] @@ -118,8 +119,8 @@ flowchart LR pkg_llm_replay --> svc_llm pkg_permission --> svc_permission pkg_sandbox --> svc_sandbox - pkg_sandbox --> svc_sandboxPolicy pkg_sandbox_local --> svc_sandbox + pkg_sandbox_policy --> svc_sandboxPolicy pkg_session --> svc_sessions pkg_session_persistence --> svc_sessionPersistence pkg_session_persistence_jsonl --> svc_sessionPersistence @@ -168,8 +169,6 @@ flowchart LR svc_sandbox --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox - svc_sandboxPolicy --> pkg_tool_bash - svc_sandboxPolicy --> pkg_tool_fs svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude @@ -228,7 +227,7 @@ flowchart LR | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | -| `ctx.sandboxPolicy` | `core` | [`sandbox`](../packages/sandbox/sandbox) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`tool-bash`](../packages/bash/tool-bash), [`tool-fs`](../packages/fs/tool-fs) | - | The one home for the deployment default mode + workspace root and the per-session `sandbox/mode` override; both enforcing families read it so bash and fs cannot confine to different roots. | +| `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/bash/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c1c7754dd2..2070c464cf 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -159,7 +159,7 @@ interface CollectedOutput { ## File sandbox: `BashSandboxInfo` -A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `bash/sandbox-mode` override and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. +A sandbox-consuming executor exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `sandbox/mode` override (owned by [`@deepseek-ai/dsh-sandbox-policy`](../../packages/sandbox/sandbox-policy/README.md)) and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only. A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl deleted file mode 100644 index 3e86a1d4e8..0000000000 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.golden.jsonl +++ /dev/null @@ -1,52 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sand"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"box"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_per"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"missions"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","title":"Write escalated.md","kind":"edit","status":"in_progress","locations":[{"path":"escalated.md"}],"content":[{"type":"diff","path":"escalated.md","oldText":null,"newText":"escalated"}]}}} -{"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","status":"completed","content":[{"type":"diff","path":"escalated.md","oldText":null,"newText":"escalated"}],"title":"Write escalated.md"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index d08c9b4550..97419b45d4 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-sandbox -Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. +Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. @@ -16,7 +16,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting. -- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. +- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/). @@ -25,11 +25,13 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego ```yaml - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' -- id: bash - name: '@deepseek-ai/dsh-bash-sandbox' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' config: mode: read-only workspaceRoot: !!js process.cwd() +- id: bash + name: '@deepseek-ai/dsh-bash-sandbox' ``` The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 30fa92b78f..ec5005ec70 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -29,7 +29,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp `BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing. -The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). +The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). `stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index d12858e6f0..314778968e 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -88,7 +88,7 @@ export class SandboxedFileSystem extends LocalFileSystem { /** * Fence the write by the per-call mode, then delegate to the inherited - * atomic write. See {@link assertWritable}. + * atomic write. See {@link checkedTarget}. * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. @@ -103,13 +103,12 @@ export class SandboxedFileSystem extends LocalFileSystem { signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise { - await this.assertWritable(target, sandboxMode) - return super.writeText(target, content, expected, signal) + return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal) } /** * Fence the edit by the per-call mode, then delegate to the inherited - * atomic edit. See {@link assertWritable}. + * atomic edit. See {@link checkedTarget}. * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. @@ -124,31 +123,34 @@ export class SandboxedFileSystem extends LocalFileSystem { signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise { - await this.assertWritable(target, sandboxMode) - return super.editText(target, edit, expected, signal) + return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal) } /** - * Enforce the per-call mode against `target` before delegating the mutation. - * `read-only` denies; `workspace-write` re-canonicalizes the target NOW - * (`resolve` realpaths the deepest existing ancestor, reflecting a - * concurrently swapped symlink) and requires containment under a writable - * root; `danger-full-access` allows. Throws the structured - * `FS_SANDBOX_DENIED` on refusal — the tool layer maps it to the model-facing - * `[sandbox: …]` marker and the escalation hint. + * Enforce the per-call mode against `target` and return the EXACT target the + * mutation must use, so the checked identity is the mutated one (no + * check-here-write-there TOCTOU). `read-only` denies; `workspace-write` + * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor, + * reflecting a concurrently swapped symlink), requires containment under a + * writable root, and returns THAT fresh target; `danger-full-access` returns + * the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on + * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker + * and the escalation hint. */ - private async assertWritable(target: FsTarget, sandboxMode?: SandboxMode): Promise { + private async checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise { const mode = sandboxMode ?? this.defaultMode - if (mode === 'danger-full-access') return + if (mode === 'danger-full-access') return target if (mode === 'read-only') { throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED') } // workspace-write: containment on the FRESH canonical path (catches a - // symlink ancestor swapped since the tool resolved this target). + // symlink ancestor swapped since the tool resolved this target), and the + // mutation delegates with THIS fresh target — never the stale one. const fresh = await this.resolve(target.displayPath) if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) { throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED') } + return fresh } } diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 095cedd695..12f0abb0df 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -14,7 +14,7 @@ import { existsSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { FsError } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -146,6 +146,19 @@ describe('workspace-write containment', () => { expect(await readFile(path, 'utf8')).toBe('changed') }) + it('mutates the freshly checked identity, not a stale outside targetKey (TOCTOU direction)', async () => { + // A target whose displayPath is inside the workspace but whose targetKey is + // a STALE outside path — as if an ancestor symlink pointed out at the tool's + // resolve() and was swapped in before the write. The fence re-resolves + // displayPath (now inside) AND delegates with that fresh target, so the byte + // lands inside and the stale outside path is never written. + const insidePath = join(workspace, 'landed.txt') + const staleTarget: FsTarget = { displayPath: insidePath, targetKey: FsTargetKey(join(outside, 'escaped.txt')) } + await fs.writeText(staleTarget, 'inside') + expect(await readFile(insidePath, 'utf8')).toBe('inside') + expect(existsSync(join(outside, 'escaped.txt'))).toBe(false) + }) + it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => { // isUnder's path-equals-root branch: the fence allows the root, and the // write then fails because the root is a directory, not a regular file. diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index 2149ee07a6..f58f9d6b13 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -112,21 +112,24 @@ export class FsSandboxSurface { } /** - * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes an - * error whose text is the shared `[sandbox: …]` denial marker plus the - * same-turn escalation hint, so a policy denial reads identically to bash's; - * any other error passes through unchanged. A `FS_SANDBOX_DENIED` only arises - * under a confining backend, which always advertises the escalation fields, - * so the hint always applies here. + * Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes a + * `FsError` whose text is the shared `[sandbox: …]` denial marker plus the + * same-turn escalation hint, so a policy denial reads identically to bash's + * WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRegistry` + * populates `result.error` only for `HarnessError` instances, so a plain + * `Error` would strip the code retry/observers key off. Any other error + * passes through unchanged. A `FS_SANDBOX_DENIED` only arises under a + * confining backend, which always advertises the escalation fields, so the + * hint always applies here. * @param error - the error thrown by the mutation. * @param stampedMode - the mode stamped onto the call (names the mode in the marker). - * @returns the error to throw — the marker error for a sandbox denial, else the original. + * @returns the error to throw — the marker `FsError` for a sandbox denial, else the original. */ mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown { if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error // A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode // (hence the resolved mode) is defined here. const mode = (stampedMode ?? this.defaultMode) as SandboxMode - return new Error(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`) + return new FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error }) } } diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts index 9303583cbb..cee0f00852 100644 --- a/packages/sandbox/sandbox-local/src/profiles.ts +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -4,9 +4,8 @@ * @module @deepseek-ai/dsh-sandbox-local/profiles */ -import { realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run' +import { writableRoots } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' /** @@ -36,31 +35,23 @@ export function landlockProfileArgs(policy: SandboxPolicy): string[] { return landlockGrantArgs({ readOnly: ['/'], readWrite }) } -/** Resolve a granted root to the canonical path the Seatbelt kernel sees. */ -function canonicalPath(path: string): string { - try { - return realpathSync(path) - } catch { - // Missing or unreadable roots stay as spelled; an unresolved root grants - // nothing until it exists, which is the conservative outcome. - return path - } -} - /** Quote one path as an SBPL string literal. */ function sbplString(path: string): string { return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"` } /** - * Build the sandbox-exec arguments and SBPL profile for one policy. + * Build the sandbox-exec arguments and SBPL profile for one policy. The + * writable roots come from the shared {@link writableRoots} helper (canonical, + * deduplicated) so the Seatbelt grant and the in-process fs fence + * (`@deepseek-ai/dsh-fs-sandbox`) can never drift apart. * @param policy - file-effect policy to express as an SBPL profile. * @returns sandbox-exec arguments before the trailing separator and command argv. */ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`] - if (policy.mode === 'workspace-write') { - const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))] + const roots = writableRoots(policy) + if (roots.length > 0) { forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) } return ['-p', forms.join(' ')] diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index f7c135f5e3..12196d89a0 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-permission -User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs. +User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs. `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 4dc5151ba0..afb0940a2a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -196,12 +196,12 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'sandboxPolicy', - pkg: 'sandbox', + pkg: 'sandbox-policy', title: 'Sandbox policy home', mode: 'core', implementations: [], - consumers: ['bash-sandbox', 'fs-sandbox', 'tool-bash', 'tool-fs'], - note: 'The one home for the deployment default mode + workspace root and the per-session `sandbox/mode` override; both enforcing families read it so bash and fs cannot confine to different roots.', + consumers: ['bash-sandbox', 'fs-sandbox'], + note: 'The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots.', }, { key: 'approval', From c16e1078f58a5576b3b95126fade6b1eee11bafd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:42:55 +0800 Subject: [PATCH 074/132] docs(agent): clarify late steering timing --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 8 ++--- .../2026-07-17-one-send-one-turn.zh.md | 8 ++--- docs/cordis-catalog/events.md | 30 +++++++++---------- docs/core-data-structures/core.md | 11 ++++--- docs/event-producer-consumer.md | 30 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 4 ++- packages/core/agent/src/types.ts | 15 ++++++---- website/zh-CN/api/harness/events.md | 30 +++++++++---------- 10 files changed, 75 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index f66fa4f240..46ee3998c3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df -2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 +2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 +2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 852a2f24d3..7eec39e1e5 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -10,7 +10,7 @@ An ordinary `Agent.send()` payload is one complete caller message. Opportunistic An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision @@ -18,7 +18,7 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered @@ -30,10 +30,10 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. - A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f40086ee20..f28c939290 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -10,7 +10,7 @@ Status: implemented 普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -30,10 +30,10 @@ Status: implemented - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6d73230a4b..555f3d629a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ba1b2e6156..68255bbd41 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -370,9 +370,12 @@ interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void @@ -400,7 +403,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5912accb71..94d16c584c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:195`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91df4913ff..acb49b1ba5 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -48,7 +48,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` joins the active turn. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index fc6382ab2d..cedb6efed9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -55,12 +55,14 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. -- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle +- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` +`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. + ### Extension points - Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 37481dbc1d..a2ddf9280d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -40,9 +40,9 @@ export interface InjectOptions extends SendOptions { /** * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (a turn is in progress), - * `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject` - * throw). + * `idle` (parked, waiting for queued work), `running` (the driver is draining + * work and may be closing or checkpointing a turn), `disposed` (terminal — no + * transition leaves it, and `send`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -106,9 +106,12 @@ export interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 01313a79df..b6ee4a3183 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L154) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L163) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L318) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L271) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L211) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca - `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L221) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L182) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L233) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L285) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L248) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L195) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L172) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L259) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L295) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L305) ## agent-loop/* From 7ffac135a6e9fa7ae1f96a2a26c82eea6c3e4e0f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:51:38 +0800 Subject: [PATCH 075/132] review fix: disambiguate steering checkpoint timing --- .../simplification/2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../simplification/2026-07-17-one-send-one-turn.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 46ee3998c3..da3da47302 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae +2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f28c939290..dde1530a4e 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 From 99ce2ce8b43c62612a49697c72aa8fc18352736f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 15:25:47 +0800 Subject: [PATCH 076/132] fix duplicate documentation homepage content --- ...026-07-13-documentation-site-projection.md | 2 ++ scripts/project-doc-site.spec.ts | 32 ++++++++++++++++++- scripts/project-doc-site.ts | 22 ++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 1ddd8fabde..b3bdbb0c1b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -14,6 +14,8 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g `scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. +Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. + The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 19417d5d99..7378152ac5 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' -import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' +import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' const roots: string[] = [] @@ -175,3 +175,33 @@ describe('addProjectionFrontmatter', () => { ) }) }) + +describe('projectedPageContent', () => { + const page = (sidebar: DocsPage['sidebar']): DocsPage => ({ + locale: 'root', + contentLocale: 'zh-CN', + source: 'docs/index.zh.md', + route: 'index.md', + label: 'Home', + sidebar, + section: 'Home', + order: 0, + }) + + it('omits the source-only body from locale home pages', () => { + expect(projectedPageContent( + '---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n', + page(null), + )).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n') + }) + + it('keeps the full body for ordinary pages', () => { + const markdown = '---\ntitle: Guide\n---\n\n# Guide\n' + expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown) + }) + + it('rejects a locale home source without frontmatter', () => { + expect(() => projectedPageContent('# Harness\n', page(null))) + .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter') + }) +}) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 5c43e6f46a..8d68aac7a6 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -268,6 +268,26 @@ export function addProjectionFrontmatter(markdown: string, sourcePath: string): return `---\n${field}\n---\n\n${markdown}` } +/** + * Select the Markdown rendered for one published page. + * + * @param markdown Rewritten canonical Markdown content. + * @param page Publication manifest entry for the content. + * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page. + */ +export function projectedPageContent(markdown: string, page: DocsPage): string { + if (page.sidebar !== null) return markdown + if (!markdown.startsWith('---\n')) { + throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`) + } + const closingDelimiter = '\n---\n' + const closing = markdown.indexOf(closingDelimiter, 4) + if (closing === -1) { + throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`) + } + return markdown.slice(0, closing + closingDelimiter.length) +} + /** Canonical Markdown files watched by the local VitePress dev server. */ export function docsSourceFiles(): string[] { return [...new Set(docsPages.map(page => resolve(root, page.source)))] @@ -297,6 +317,6 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, }) - writeFileSync(output, addProjectionFrontmatter(projected, page.source)) + writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source)) } } From f899b857a613ba7e789f51cd62e2ee5db3c0370f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 15:48:42 +0800 Subject: [PATCH 077/132] review fix: explain one-send turns plainly --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 34 ++++++++++-------- .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++-------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index da3da47302..e441d0bfb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d +2026-07-17-one-send-one-turn.md: 86c056b53700d0e0c02e04a99cf044fb311f5840 +2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 7eec39e1e5..86c056b537 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -6,34 +6,40 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) ## Problem -An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. +Suppose a caller submits message A and then message B with two `Agent.send()` calls. Implicit batching can put A and B in one turn simply because both are waiting when the driver reads its queue. The caller made two calls, but the loop silently turns them into one unit of work. -An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. -`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +This grouping changes behavior, not just the number of model calls. One ordinary turn owns prompt admission, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Allowing one message while blocking another also requires a mixed state that no caller requested. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. +Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. -Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. + +Prompt admission decides one message at a time. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist. + +The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in a separate steering FIFO. While a turn remains open, the loop records that input at the next steering checkpoint, which comes before either a model request or the decision whether to continue. Steering makes another step the default, but continuation or terminal policy can still stop before the step starts. Steering left after the turn closes and its durability checkpoint settles becomes later queued input; terminal `agent/turn-stop`, cancellation, or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. + +`inject()` continues to add model-facing context without submitting an ordinary message; its existing turn-enclosure and flush behavior stays unchanged. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, including turn close and its checkpoint, so `running` does not prove that a turn is open. ## Alternatives considered -**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. +**Keep automatic ordinary-send batching to reduce model calls.** This can improve throughput when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and reaches its checkpoint. The decision keeps the predictable boundary and accepts the extra calls. Any future batching feature needs an explicit caller-visible contract backed by measurements. ## Verification -- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. -- A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. -- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. +- A built-stdio test submits two lines and observes two model requests and two turn boundaries. +- Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. +- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains. +- Separate tests cover open-turn, post-turn-close, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion or cancellation handle; broad cancellation can discard the entire unstarted tail, while status and quiescence remain agent-wide observations. -Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. +The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index dde1530a4e..3ef9973480 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 移除普通 send 的隐式批处理 +# Agent Note: 删除普通 send 的隐式批处理 Status: implemented @@ -6,34 +6,40 @@ Status: implemented ## 问题 -每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 +假设调用方连续两次调用 `Agent.send()`,先提交消息 A,再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待,就把 A、B 放进同一个轮次。调用方明明调用了两次,agent loop(智能体循环)却悄悄把它们变成一个工作单元。 -普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息,还需要引入调用方没有请求的混合状态。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 +队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 + +提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 + +上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering(中途引导)FIFO。只要当前轮次仍然打开,agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`、取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 + +`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 -**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**保留普通 send 的自动批处理,以减少模型调用。** 当消息进入队列的速度超过驱动器的处理速度时,这种做法可以提高吞吐量,但会让轮次边界取决于调度,并让后一条消息在前一轮关闭且到达检查点之前运行。本决策保留可预测的边界,并接受额外调用。未来若要加入批处理功能,必须提供调用方可见的显式契约,并有测量结果作为依据。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 +- stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 +- 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 +- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄;广义取消可以丢弃整个尚未启动的队尾,状态和静止性也仍是面向整个 agent 的观察。 -依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 +代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 From b25b78fd0264dced2dff73f97e6fee9c3bc1f851 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:00:36 +0800 Subject: [PATCH 078/132] fix(session): project steering messages as plain user content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit steering/message previously rendered inside a envelope like context/message. No model is trained on a tag, so the framing is arbitrary markup the model was never taught to read; recorded transcripts show it treating the instruction as third-party metadata and refusing it. Framing also does not belong on the session surface — a caller that wants a frame formats its own content. It now projects verbatim as a plain user-role message; the envelope is untouched and renderTagged becomes context-only. Agent Note: .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md --- .../2026-06-11-content-block-vocabulary.md | 2 +- ...wrap-steering-message-projection.i18n.yaml | 6 + ...7-20-unwrap-steering-message-projection.md | 26 ++ ...0-unwrap-steering-message-projection.zh.md | 26 ++ docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 2 +- .../hook-cc-stop-continue/session.jsonl | 259 +++++------------- .../stdout.expected.jsonl | 139 +--------- .../hook-codex-stop-continue/session.jsonl | 134 ++++----- .../stdout.expected.jsonl | 12 +- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 13 +- packages/core/session/tests/session.spec.ts | 5 +- packages/core/session/tests/surface.spec.ts | 2 +- website/zh-CN/api/harness/sessions.md | 18 +- 15 files changed, 227 insertions(+), 421 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md create mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index 35d40ad0f4..c286efe2d2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log, Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. -In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. +In-session context injection (`context/message`) renders as a tagged user-role envelope (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. `steering/message` originally shared the envelope but now projects as plain user content; see [the steering-unwrap Agent Note](../simplification/2026-07-20-unwrap-steering-message-projection.md). ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml new file mode 100644 index 0000000000..e8d3550e66 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-unwrap-steering-message-projection.md: 93be7191a556b6438a0ad265d3cc9bde29f5d0ef +2026-07-20-unwrap-steering-message-projection.zh.md: 8e3fcd0fbf25c393e7429b0d24bd9a1e38d61e48 diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md new file mode 100644 index 0000000000..93be7191a5 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md @@ -0,0 +1,26 @@ +# Agent Note: Project steering messages as plain user content + +Status: implemented + +English | [中文](2026-07-20-unwrap-steering-message-projection.zh.md) + +## Problem + +`Session.deriveEventMessage` rendered `steering/message` inside a `` envelope, mirroring the `context/message` framing. But the two events differ in kind: context injection is ambient, non-conversational material (file-change notices, workspace instructions) where the envelope tells the model "this is not the user speaking", while steering *is* the user (or a plugin acting for the user) speaking mid-turn — "also reply with SECOND", "focus on tests". Wrapping that direction in an XML label distances the model from an instruction it should treat as a first-class user message; recorded transcripts show models reasoning about whether to obey "the steering input" as if it were third-party metadata. + +## Decision + +`steering/message` projects to a plain user-role message carrying its content blocks verbatim — identical to `user/message` projection. The `` envelope on `context/message` (with its `raw` opt-out) is untouched. The former `renderTagged` helper in `packages/core/session/src/index.ts` is now the context-only `renderContextEnvelope` with no tag parameter. The compaction renderer's `[Steering: …]` label is unaffected: that is a summarization-input format, not model-visible history. + +The `source` attribution that the envelope carried is not lost — it remains on the durable `steering/message` event; it just no longer renders into the model transcript. + +## Alternatives considered + +- **Keep the envelope for plugin-sourced steering only** — splits one projection into two on `source.kind` for no observed benefit; a plugin steering the agent (hook-bridge continuation reasons) also wants the instruction followed, not attributed. +- **Move the unwrapping into adapters** — the canonical projection is the model-visible contract ("model-visible ⟺ logged"); per-adapter divergence on framing would make the derived transcript adapter-dependent. + +## Consequences + +- Mid-turn steering reaches the model with the same weight as an ordinary user prompt. +- The transcript no longer distinguishes a steering injection from a user message; consumers that need the distinction read the durable event log, which keeps `steering/message` and its `source` intact. +- The [content-block-vocabulary Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)'s tagged-envelope clause now covers `context/message` only and is amended to point here. diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md new file mode 100644 index 0000000000..8e3fcd0fbf --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md @@ -0,0 +1,26 @@ +# Agent Note: steering 消息投影为普通用户内容 + +Status: implemented + +[English](2026-07-20-unwrap-steering-message-projection.md) | 中文 + +## 问题 + +`Session.deriveEventMessage` 曾把 `steering/message` 包在 `` 封套里渲染,与 `context/message` 的框架保持一致。但这两类事件性质不同:上下文注入是环境性的、非对话性的材料(文件变更通知、工作区指令),封套告诉模型「这不是用户在说话」;而 steering(中途引导)恰恰**是**用户(或代表用户的插件)在轮次中途发言——「再回复 SECOND」「专注于测试」。把这种指令包进 XML 标签会让模型把本应作为一等用户消息对待的指令当成第三方元数据;已录制的 transcript(文本记录)显示,模型会推理是否要服从「那条 steering 输入」,仿佛它是旁观者的附注。 + +## 决策 + +`steering/message` 投影为普通的 user 角色消息,逐字携带其内容块——与 `user/message` 的投影完全相同。`context/message` 上的 `` 封套(及其 `raw` 退出选项)保持不变。`packages/core/session/src/index.ts` 中原来的 `renderTagged` 辅助函数现在是只服务于 context 的 `renderContextEnvelope`,不再接受标签参数。压缩(compaction)渲染器的 `[Steering: …]` 标注不受影响:那是摘要输入格式,不是模型可见的历史。 + +封套曾携带的 `source` 归属并未丢失——它仍保留在持久的 `steering/message` 事件上;只是不再渲染进模型 transcript。 + +## 备选方案 + +- **仅对插件来源的 steering 保留封套** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent(智能体)时(钩子桥接器的轮次续行原因)同样希望指令被遵从,而不是被归因。 +- **把去封套的逻辑移入适配器** —— 规范投影就是模型可见契约(「模型可见 ⟺ 已记录」);让各适配器在框架上各行其是,会使派生的 transcript 依赖于适配器。 + +## 影响 + +- 中途引导以与普通用户提示相同的权重到达模型。 +- transcript 不再区分 steering 注入与用户消息;需要这一区分的消费方读取持久事件日志,其中 `steering/message` 及其 `source` 完整保留。 +- [内容块词汇表 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md) 中关于带标签封套的条款现在只覆盖 `context/message`,并已修订为指向本文。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..4d7c208510 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -820,7 +820,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:574`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 263d991f86..1b9dfb1a9e 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -433,7 +433,7 @@ declare class Session { - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. - `context/message` → a user-role message at its chronological position. The default `envelope` is `context`, which wraps content as ``; `envelope: 'raw'` uses caller-owned framing verbatim. Optional JSON `meta` remains in the event log and is never rendered. -- `steering/message` → a user-role message wrapped in `` at its chronological position. +- `steering/message` → a user-role message carrying its content verbatim at its chronological position. Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 5f6b0e6e9e..baa6a92e9a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,192 +1,67 @@ -{"type":"session","version":0,"id":"02bb4dcf-ffd6-4111-909b-504c7006d821","createdAt":1783352203365,"cwd":"/tmp/acp-snap-cwd-7YNbji"} -{"type":"turn/start","seq":0,"time":1783352203369,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352203370,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352203371,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352203372,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352204036,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352204036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352204247,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352204282,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352204283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352204283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352204283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352204283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352204284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":13,"time":1783352204317,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":14,"time":1783352204318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1783352204318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1783352204318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} -{"type":"assistant/chunk","seq":17,"time":1783352204318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} -{"type":"assistant/chunk","seq":18,"time":1783352204318,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":21,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} -{"type":"assistant/chunk","seq":24,"time":1783352204353,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} -{"type":"assistant/chunk","seq":25,"time":1783352204393,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} -{"type":"assistant/chunk","seq":26,"time":1783352204393,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} -{"type":"assistant/chunk","seq":27,"time":1783352204393,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2862,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":28,"time":1783352204393,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1783352204396,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2862,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":1783352204396,"data":{"turn":1,"step":1}} -{"type":"hook/invoked","seq":31,"time":1783352204396,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} -{"type":"hook/result","seq":32,"time":1783352204443,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":47.01462700000047}} -{"type":"steering/message","seq":33,"time":1783352204444,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} -{"type":"step/start","seq":34,"time":1783352204444,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":35,"time":1783352204945,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":36,"time":1783352204946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":37,"time":1783352205054,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":38,"time":1783352205086,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":39,"time":1783352205087,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":40,"time":1783352205087,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":41,"time":1783352205087,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":42,"time":1783352205115,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":43,"time":1783352205115,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":44,"time":1783352205116,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} -{"type":"assistant/chunk","seq":45,"time":1783352205116,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} -{"type":"assistant/chunk","seq":46,"time":1783352205116,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352205143,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":48,"time":1783352205172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":49,"time":1783352205172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":50,"time":1783352205172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} -{"type":"assistant/chunk","seq":51,"time":1783352205200,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":52,"time":1783352205201,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":53,"time":1783352205201,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} -{"type":"assistant/chunk","seq":54,"time":1783352205201,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" input"}}} -{"type":"assistant/chunk","seq":55,"time":1783352205231,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" telling"}}} -{"type":"assistant/chunk","seq":56,"time":1783352205232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":57,"time":1783352205232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":58,"time":1783352205232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" also"}}} -{"type":"assistant/chunk","seq":59,"time":1783352205266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":60,"time":1783352205266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":61,"time":1783352205266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":62,"time":1783352205266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} -{"type":"assistant/chunk","seq":63,"time":1783352205266,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} -{"type":"assistant/chunk","seq":64,"time":1783352205267,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":65,"time":1783352205288,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" However"}}} -{"type":"assistant/chunk","seq":66,"time":1783352205318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":67,"time":1783352205319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":68,"time":1783352205319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":69,"time":1783352205319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":70,"time":1783352205319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explicit"}}} -{"type":"assistant/chunk","seq":71,"time":1783352205359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":72,"time":1783352205359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":73,"time":1783352205373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":74,"time":1783352205373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":75,"time":1783352205373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":76,"time":1783352205373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":77,"time":1783352205401,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":78,"time":1783352205402,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":79,"time":1783352205402,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} -{"type":"assistant/chunk","seq":80,"time":1783352205430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":81,"time":1783352205430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":82,"time":1783352205431,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":83,"time":1783352205431,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":84,"time":1783352205431,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} -{"type":"assistant/chunk","seq":85,"time":1783352205431,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":86,"time":1783352205457,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} -{"type":"assistant/chunk","seq":87,"time":1783352205457,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":88,"time":1783352205458,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} -{"type":"assistant/chunk","seq":89,"time":1783352205485,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":90,"time":1783352205486,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":91,"time":1783352205514,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":92,"time":1783352205515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":93,"time":1783352205515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" themselves"}}} -{"type":"assistant/chunk","seq":94,"time":1783352205542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":95,"time":1783352205542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":96,"time":1783352205542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":97,"time":1783352205543,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} -{"type":"assistant/chunk","seq":98,"time":1783352205543,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":99,"time":1783352205543,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":100,"time":1783352205571,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":101,"time":1783352205571,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" original"}}} -{"type":"assistant/chunk","seq":102,"time":1783352205601,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":103,"time":1783352205601,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":104,"time":1783352205601,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Wait"}}} -{"type":"assistant/chunk","seq":105,"time":1783352205601,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":106,"time":1783352205601,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} -{"type":"assistant/chunk","seq":107,"time":1783352205629,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":108,"time":1783352205629,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" re"}}} -{"type":"assistant/chunk","seq":109,"time":1783352205630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-read"}}} -{"type":"assistant/chunk","seq":110,"time":1783352205630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":111,"time":1783352205630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":112,"time":1783352205630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":113,"time":1783352205656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":114,"time":1783352205656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":115,"time":1783352205657,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} -{"type":"assistant/chunk","seq":116,"time":1783352205657,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":117,"time":1783352205657,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":118,"time":1783352205657,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":119,"time":1783352205687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":120,"time":1783352205687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} -{"type":"assistant/chunk","seq":121,"time":1783352205687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":122,"time":1783352205687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":123,"time":1783352205687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} -{"type":"assistant/chunk","seq":124,"time":1783352205688,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} -{"type":"assistant/chunk","seq":125,"time":1783352205712,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" there"}}} -{"type":"assistant/chunk","seq":126,"time":1783352205713,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":127,"time":1783352205713,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":128,"time":1783352205740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} -{"type":"assistant/chunk","seq":129,"time":1783352205741,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} -{"type":"assistant/chunk","seq":130,"time":1783352205770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":131,"time":1783352205770,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"plugin"}}} -{"type":"assistant/chunk","seq":132,"time":1783352205771,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":133,"time":1783352205801,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" saying"}}} -{"type":"assistant/chunk","seq":134,"time":1783352205802,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":135,"time":1783352205802,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Also"}}} -{"type":"assistant/chunk","seq":136,"time":1783352205802,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":137,"time":1783352205829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":138,"time":1783352205829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":139,"time":1783352205829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":140,"time":1783352205829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":141,"time":1783352205829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" SECOND"}}} -{"type":"assistant/chunk","seq":142,"time":1783352205830,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":143,"time":1783352205857,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":144,"time":1783352205857,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":145,"time":1783352205858,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} -{"type":"assistant/chunk","seq":146,"time":1783352205858,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":147,"time":1783352205858,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":148,"time":1783352205886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":149,"time":1783352205886,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":150,"time":1783352205914,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":151,"time":1783352205915,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" clear"}}} -{"type":"assistant/chunk","seq":152,"time":1783352205942,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":153,"time":1783352205971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":154,"time":1783352205971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":155,"time":1783352205971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" FIRST"}}} -{"type":"assistant/chunk","seq":156,"time":1783352205971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":157,"time":1783352206029,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":158,"time":1783352206030,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":159,"time":1783352206030,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":160,"time":1783352206030,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" steering"}}} -{"type":"assistant/chunk","seq":161,"time":1783352206030,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":162,"time":1783352206030,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" from"}}} -{"type":"assistant/chunk","seq":163,"time":1783352206044,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":164,"time":1783352206044,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" plugin"}}} -{"type":"assistant/chunk","seq":165,"time":1783352206044,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" source"}}} -{"type":"assistant/chunk","seq":166,"time":1783352206074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":167,"time":1783352206074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":168,"time":1783352206074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":169,"time":1783352206074,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":170,"time":1783352206116,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":171,"time":1783352206117,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":172,"time":1783352206117,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} -{"type":"assistant/chunk","seq":173,"time":1783352206117,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} -{"type":"assistant/chunk","seq":174,"time":1783352206139,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":175,"time":1783352206140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":176,"time":1783352206140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":177,"time":1783352206140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":178,"time":1783352206140,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":179,"time":1783352206161,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":180,"time":1783352206161,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} -{"type":"assistant/chunk","seq":181,"time":1783352206162,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} -{"type":"assistant/chunk","seq":182,"time":1783352206162,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with \"FIRST\" only, but there's a steering input telling me to also reply with \"SECOND\". However, the user's explicit instruction is to reply with the single word FIRST and stop. The steering is from a plugin, not the user themselves. I should follow the user's original instruction.\n\nWait, let me re-read. The user said \"Reply with the single word FIRST and stop.\" Then there's a steering from \"plugin\" saying \"Also reply with the single word SECOND, then stop.\"\n\nThe user's instruction is clear: reply with FIRST and stop. The steering is from a plugin source, not the user. I'll follow the user's instruction."}}}} -{"type":"assistant/chunk","seq":183,"time":1783352206162,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} -{"type":"assistant/chunk","seq":184,"time":1783352206162,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":73,"outputTokens":146,"cacheReadTokens":2816,"reasoningTokens":143}}}} -{"type":"assistant/chunk","seq":185,"time":1783352206162,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":186,"time":1783352206162,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user wants me to reply with \"FIRST\" only, but there's a steering input telling me to also reply with \"SECOND\". However, the user's explicit instruction is to reply with the single word FIRST and stop. The steering is from a plugin, not the user themselves. I should follow the user's original instruction.\n\nWait, let me re-read. The user said \"Reply with the single word FIRST and stop.\" Then there's a steering from \"plugin\" saying \"Also reply with the single word SECOND, then stop.\"\n\nThe user's instruction is clear: reply with FIRST and stop. The steering is from a plugin source, not the user. I'll follow the user's instruction."},{"type":"text","text":"FIRST"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":73,"outputTokens":146,"cacheReadTokens":2816,"reasoningTokens":143}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185],"surfaceOp":"append"} -{"type":"step/end","seq":187,"time":1783352206163,"data":{"turn":1,"step":2}} -{"type":"hook/invoked","seq":188,"time":1783352206163,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} -{"type":"hook/result","seq":189,"time":1783352206190,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":26.904655000000275}} -{"type":"turn/end","seq":190,"time":1783352206190,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-r6rWZp"} +{"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784522140648,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784522140648,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784522142865,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784522142865,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784522142866,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":13,"time":1784522142876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1784522142876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1784522142876,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1784522142877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":17,"time":1784522142877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":18,"time":1784522142877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1784522142904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1784522142904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":21,"time":1784522142904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1784522142905,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1784522142905,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":24,"time":1784522142905,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":25,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":26,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":27,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":28,"time":1784522142942,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1784522142947,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with just the word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1784522142947,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":31,"time":1784522142947,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} +{"type":"hook/result","seq":32,"time":1784522142962,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.349833000000217}} +{"type":"steering/message","seq":33,"time":1784522142962,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"step/start","seq":34,"time":1784522142963,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":35,"time":1784522143914,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":36,"time":1784522143914,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":37,"time":1784522144018,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":38,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":39,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":40,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1784522144049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":45,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":46,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":47,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":48,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":49,"time":1784522144077,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":51,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":52,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":53,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":55,"time":1784522144135,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":56,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":57,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"type":"assistant/chunk","seq":58,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":59,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":60,"time":1784522144141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":61,"time":1784522144142,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1784522144142,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":63,"time":1784522144142,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} +{"type":"hook/result","seq":64,"time":1784522144144,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":2.5859159999999974}} +{"type":"turn/end","seq":65,"time":1784522144145,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index 39cf7df191..2d92b5b3e7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -7,8 +7,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} @@ -26,142 +26,17 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FIR"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" input"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" telling"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" also"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" However"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicit"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" themselves"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" original"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Wait"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" re"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-read"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" there"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"plugin"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" saying"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Also"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SECOND"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" clear"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" FIRST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" steering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" plugin"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" source"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIR"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OND"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index bba879203d..cee758076c 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,67 +1,67 @@ -{"type":"session","version":0,"id":"bc6b18d1-d10e-481c-9b9c-c92d9188db3a","createdAt":1783352235015,"cwd":"/tmp/acp-snap-cwd-iHVZRl"} -{"type":"turn/start","seq":0,"time":1783352235020,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352235020,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352235022,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352235023,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352235669,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352235670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352235879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352235894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352235894,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352235895,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352235895,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1783352235925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1783352235926,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} -{"type":"assistant/chunk","seq":13,"time":1783352235955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1783352235956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":15,"time":1783352235956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":16,"time":1783352235956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":17,"time":1783352235956,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} -{"type":"assistant/chunk","seq":18,"time":1783352235982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} -{"type":"assistant/chunk","seq":19,"time":1783352235983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":20,"time":1783352235983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":21,"time":1783352235983,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":22,"time":1783352236011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":23,"time":1783352236011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1783352236012,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1783352236012,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} -{"type":"assistant/chunk","seq":26,"time":1783352236012,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} -{"type":"assistant/chunk","seq":27,"time":1783352236041,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with only the single word \"FIRST\" and then stop."}}}} -{"type":"assistant/chunk","seq":28,"time":1783352236041,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} -{"type":"assistant/chunk","seq":29,"time":1783352236041,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2862,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":30,"time":1783352236041,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":31,"time":1783352236043,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with only the single word \"FIRST\" and then stop."},{"type":"text","text":"FIRST"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2862,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} -{"type":"step/end","seq":32,"time":1783352236043,"data":{"turn":1,"step":1}} -{"type":"hook/invoked","seq":33,"time":1783352236043,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} -{"type":"hook/result","seq":34,"time":1783352236059,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.77945499999987}} -{"type":"steering/message","seq":35,"time":1783352236059,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} -{"type":"step/start","seq":36,"time":1783352236059,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":37,"time":1783352236629,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":38,"time":1783352236629,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":39,"time":1783352236730,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":40,"time":1783352236758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":41,"time":1783352236759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":42,"time":1783352236759,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":43,"time":1783352236788,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":44,"time":1783352236788,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":45,"time":1783352236788,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":46,"time":1783352236789,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":47,"time":1783352236817,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":48,"time":1783352236818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":49,"time":1783352236818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" SECOND"}}} -{"type":"assistant/chunk","seq":50,"time":1783352236818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":51,"time":1783352236818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":52,"time":1783352236846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":53,"time":1783352236846,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":54,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":55,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} -{"type":"assistant/chunk","seq":56,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} -{"type":"assistant/chunk","seq":57,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to reply with the single word SECOND, then stop."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} -{"type":"assistant/chunk","seq":59,"time":1783352236876,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":73,"outputTokens":19,"cacheReadTokens":2816,"reasoningTokens":16}}}} -{"type":"assistant/chunk","seq":60,"time":1783352236877,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":61,"time":1783352236877,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user is asking me to reply with the single word SECOND, then stop."},{"type":"text","text":"SECOND"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":73,"outputTokens":19,"cacheReadTokens":2816,"reasoningTokens":16}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"step/end","seq":62,"time":1783352236877,"data":{"turn":1,"step":2}} -{"type":"hook/invoked","seq":63,"time":1783352236877,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} -{"type":"hook/result","seq":64,"time":1783352236908,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":30.947317000000112}} -{"type":"turn/end","seq":65,"time":1783352236909,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-ESgqLu"} +{"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784522152399,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784522152399,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784522153542,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1784522153542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1784522153749,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1784522153750,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1784522153750,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1784522153751,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1784522153751,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1784522153751,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":14,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FIR"}}} +{"type":"assistant/chunk","seq":17,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ST"}}} +{"type":"assistant/chunk","seq":18,"time":1784522153752,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1784522153761,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1784522153761,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":21,"time":1784522153761,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1784522153761,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1784522153762,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FIR"}}} +{"type":"assistant/chunk","seq":24,"time":1784522153762,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ST"}}} +{"type":"assistant/chunk","seq":25,"time":1784522153785,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} +{"type":"assistant/chunk","seq":26,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST"}}}} +{"type":"assistant/chunk","seq":27,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":28,"time":1784522153786,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1784522153790,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3545,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1784522153790,"data":{"turn":1,"step":1}} +{"type":"hook/invoked","seq":31,"time":1784522153791,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} +{"type":"hook/result","seq":32,"time":1784522153806,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.605791999999838}} +{"type":"steering/message","seq":33,"time":1784522153806,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"step/start","seq":34,"time":1784522153806,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":35,"time":1784522154765,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":36,"time":1784522154765,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":37,"time":1784522154866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":38,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":39,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":40,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1784522154898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":1784522154924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":45,"time":1784522154925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":46,"time":1784522154925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":47,"time":1784522154925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"SEC"}}} +{"type":"assistant/chunk","seq":48,"time":1784522154925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OND"}}} +{"type":"assistant/chunk","seq":49,"time":1784522154925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784522154950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":51,"time":1784522154951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":52,"time":1784522154951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":53,"time":1784522154951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1784522154951,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":55,"time":1784522154951,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"SEC"}}} +{"type":"assistant/chunk","seq":56,"time":1784522154978,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OND"}}} +{"type":"assistant/chunk","seq":57,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"type":"assistant/chunk","seq":58,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SECOND"}}}} +{"type":"assistant/chunk","seq":59,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":60,"time":1784522154980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":61,"time":1784522154981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"SECOND\" and then stop."},{"type":"text","text":"SECOND"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":106,"outputTokens":21,"cacheReadTokens":3456,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1784522154982,"data":{"turn":1,"step":2}} +{"type":"hook/invoked","seq":63,"time":1784522154982,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} +{"type":"hook/result","seq":64,"time":1784522154990,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":7.6766670000001795}} +{"type":"turn/end","seq":65,"time":1784522154990,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index 821d648791..a3e72075ed 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -7,7 +7,6 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" only"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} @@ -16,15 +15,13 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ST"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ST"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} @@ -32,8 +29,11 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SECOND"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SEC"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OND"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a60be8da72..d164a8ffc8 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 16732f7903..19b78cde47 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -85,13 +85,11 @@ declare module 'cordis' { * canonical session vocabulary provider-neutral. Adapter-specific exceptions * belong in the adapter. */ -function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] { - const open = `<${tag} source=${JSON.stringify(source.kind)}>` - const close = `` +function renderContextEnvelope(content: ContentBlock[], source: MessageSource): ContentBlock[] { return [ - { type: 'text', text: open }, + { type: 'text', text: `` }, ...content, - { type: 'text', text: close }, + { type: 'text', text: '' }, ] } @@ -241,7 +239,7 @@ export function renderContextContent( envelope: ContextEnvelope = 'context', ): ContentBlock[] { const cloned = structuredClone(content) - return envelope === 'raw' ? cloned : renderTagged('context', cloned, source) + return envelope === 'raw' ? cloned : renderContextEnvelope(cloned, source) } /** @@ -531,8 +529,7 @@ export class Session { return { role: 'user', content: renderContextContent(content, source, envelope) } } case 'steering/message': { - const { content, source } = event.data - return { role: 'user', content: renderTagged('steering', content, source) } + return { role: 'user', content: event.data.content } } default: // A non-surface event (boundary, chunk, log-only record) projects to diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 3b15157bde..93bfe132cd 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -48,7 +48,7 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) - it('renders context and steering messages as tagged synthetic user content', () => { + it('renders context messages tagged and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) session.append('context/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], @@ -64,7 +64,8 @@ describe('Session', () => { expect(contextMessage!.role).toBe('user') expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '' }) expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '' }) - expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '' }) + expect(steeringMessage!.role).toBe('user') + expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }]) }) it('renders raw context without a generic envelope while preserving structured metadata', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index d239533476..3d983f1501 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -370,7 +370,7 @@ describe('deriveMessages with surface', () => { const messages = s.deriveMessages() expect(messages).toHaveLength(2) expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '' }) - expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '' }) + expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }]) }) }) diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index f59001009d..af43a0227c 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L577) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L574) ### ctx.sessions.create(id?, options?) @@ -44,7 +44,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L606) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L603) ### ctx.sessions.prepare(id?, options?) @@ -75,7 +75,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L635) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L632) ### ctx.sessions.enter(session) @@ -112,7 +112,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L679) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L676) ### ctx.sessions.announce(session) @@ -131,7 +131,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L734) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L731) ### ctx.sessions.flush(session) @@ -156,7 +156,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L786) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L783) ### ctx.sessions.get(id) @@ -175,7 +175,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L818) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L815) ### ctx.sessions.list() @@ -191,7 +191,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L823) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -220,4 +220,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L843) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L840) From f593ef5ab577b9cee92ffff0241ba3c0f6f0381d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 14:37:04 +0800 Subject: [PATCH 079/132] fix(session): drop the envelope and project context verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context/message previously defaulted to a wrapper. No model is trained on a tag either, and message framing does not belong on the session surface: the surface projects the durable log, and a caller that wants a frame formats its own content — which the one heavy producer (workspace-context) already does with its own frame, opting out via 'raw'. The tag only added machinery — ContextEnvelope plus an envelope field threaded through InjectOptions, HookContext, the context/message event, and the agent-loop inject/additionalContexts plumbing. context/message now projects its content verbatim as a user-role message, sharing one deriveEventMessage case with user/message and steering/message. ContextEnvelope and every envelope field are removed; context/message.meta still carries durable, model-hidden JSON state. Regenerated catalogs and website API; refreshed the three affected keyless snapshots (envelope field only; timestamps unchanged). Broadens and renames the steering Agent Note to cover both envelope removals as one decision. Agent Note: .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md --- .../2026-06-11-content-block-vocabulary.md | 2 +- .../feature/2026-06-24-workspace-context.md | 4 +- .../feature/2026-06-30-interception-seams.md | 2 +- ...wrap-injected-content-envelopes.i18n.yaml} | 4 +- ...07-20-unwrap-injected-content-envelopes.md | 41 ++++++++++++++ ...20-unwrap-injected-content-envelopes.zh.md | 41 ++++++++++++++ ...7-20-unwrap-steering-message-projection.md | 26 --------- ...0-unwrap-steering-message-projection.zh.md | 26 --------- docs/cordis-catalog/events.md | 30 +++++------ docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 10 ++-- docs/core-data-structures/session.md | 22 +++----- docs/core-data-structures/tools.md | 4 +- docs/event-producer-consumer.md | 30 +++++------ docs/persistence-catalog.md | 25 +++++---- .../code-mode-workspace-context/session.jsonl | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../stdout.expected.jsonl | 2 +- .../snapshots/workspace-context/session.jsonl | 2 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/src/index.ts | 1 - .../context/workspace-context/src/render.ts | 6 +++ .../context/workspace-context/src/state.ts | 5 +- .../tests/workspace-context.spec.ts | 10 +--- .../cordis/tool-cordis/src/api-catalog.ts | 10 ++-- packages/core/agent-loop/src/agent.ts | 1 - packages/core/agent-loop/src/loop.ts | 3 +- .../agent-loop/tests/interception.spec.ts | 6 +-- packages/core/agent-loop/tests/loop.spec.ts | 9 ++-- packages/core/agent/README.md | 4 +- packages/core/agent/src/types.ts | 6 +-- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 53 +++++-------------- packages/core/session/src/types.ts | 14 ++--- packages/core/session/tests/session.spec.ts | 8 ++- packages/core/session/tests/surface.spec.ts | 2 +- packages/core/tools/README.md | 4 +- packages/core/tools/src/index.ts | 4 +- packages/core/tools/tests/code-mode.spec.ts | 3 -- packages/core/tools/tests/tools.spec.ts | 3 +- packages/guard/repeat-tool-guard/README.md | 2 +- packages/guard/repeat-tool-guard/src/index.ts | 2 +- .../hooks-claude/tests/coverage-cases.ts | 4 -- .../hooks/hooks-codex/tests/coverage-cases.ts | 4 -- scripts/type-equiv.manifest.json | 1 - website/zh-CN/api/harness/events.md | 30 +++++------ website/zh-CN/api/harness/sessions.md | 18 +++---- 47 files changed, 230 insertions(+), 266 deletions(-) rename .agents/notes/implemented/simplification/{2026-07-20-unwrap-steering-message-projection.i18n.yaml => 2026-07-20-unwrap-injected-content-envelopes.i18n.yaml} (61%) create mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md create mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index c286efe2d2..1133b990c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log, Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. -In-session context injection (`context/message`) renders as a tagged user-role envelope (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. `steering/message` originally shared the envelope but now projects as plain user content; see [the steering-unwrap Agent Note](../simplification/2026-07-20-unwrap-steering-message-projection.md). +In-session context injection (`context/message`) and mid-turn steering (`steering/message`) originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index dc21fb919d..a3eec63b8f 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -40,7 +40,7 @@ After a successful first-party `read`, `write`, or `edit` call, the `tools/post- A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. -Dynamic messages use a raw `context/message` envelope because the plugin owns the complete system-reminder framing. Core context injection therefore supports `envelope: 'raw'`; callers that omit it retain the canonical `` wrapper. `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. +Dynamic messages carry their complete system-reminder framing in `content`, and every `context/message` reaches the model verbatim as a user-role message (there is no core wrapper to opt out of). `context/message.meta` carries opaque JSON state that is persisted but never rendered to the model. Shell commands are not discovery triggers. Local bash calls start fresh shells, and inferring reached paths from arbitrary command strings would require shell semantics the prompt plugin does not own. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract includes optional raw framing and JSON metadata, both propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, delimiter escaping, and symlink rejection reduce risk but do not eliminate prompt injection. Permission and sandbox layers treat workspace files as data rather than authority. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index c0a25081af..369b088219 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,7 +16,7 @@ The canonical surface separates transformable policy, around-dispatch control, a - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. - `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching separately sourced `additionalContexts[]`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). -**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer a context envelope or durable context metadata. +**`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer durable context metadata. ### The tool pipeline gives each phase one kind of authority diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml similarity index 61% rename from .agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml index e8d3550e66..edb7429454 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-unwrap-steering-message-projection.md: 93be7191a556b6438a0ad265d3cc9bde29f5d0ef -2026-07-20-unwrap-steering-message-projection.zh.md: 8e3fcd0fbf25c393e7429b0d24bd9a1e38d61e48 +2026-07-20-unwrap-injected-content-envelopes.md: 32642660f7bcea748c349933b99552b1974922c5 +2026-07-20-unwrap-injected-content-envelopes.zh.md: a01a51e12cecca5bc46526ccca61dbe90eb3136f diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md new file mode 100644 index 0000000000..32642660f7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md @@ -0,0 +1,41 @@ +# Agent Note: Project injected content verbatim, dropping the XML envelopes + +Status: implemented + +English | [中文](2026-07-20-unwrap-injected-content-envelopes.zh.md) + +## Problem + +Two families of injected session content rendered into the model transcript wrapped in XML envelopes: `steering/message` as `` and `context/message` as `` (the latter with a `'raw'` opt-out that skipped the wrapper). The envelopes aimed to tell the model "this is injected, not the user speaking." + +Two problems: + +- **No model is trained on these tags.** `` and `` are arbitrary markup no model was taught to read, so the framing adds tokens without a reliable effect and can actively mislead — recorded transcripts show a model treating a `` instruction as third-party metadata and refusing it while answering only the original prompt. +- **The session surface is the wrong layer for framing.** The surface projects the durable log into the model transcript; deciding how content is worded is not its job. A caller that wants a particular frame formats its own content before injecting it — which the one heavy producer (`workspace-context`) already does, owning its complete `` frame and opting out of the `` wrapper with `envelope: 'raw'`. The remaining tag machinery (`ContextEnvelope`, an `envelope` field threaded through `InjectOptions`, `HookContext`, the `context/message` event, and the loop) served a distinction that belongs to the caller. + +## Decision + +Injected session content projects verbatim; the caller owns any framing. `deriveEventMessage` renders `user/message`, `context/message`, and `steering/message` through one shared case returning `{ role: 'user', content: event.data.content }`; their content blocks reach the model unchanged. `context/message`'s `source`/`meta` and `steering/message`'s `turn` stay in the durable event log but do not render. + +The `ContextEnvelope` type and every `envelope` field are removed — `context/message` in `SessionEventMap`, `InjectOptions`, `HookContext`, and the `inject()`/`additionalContexts` plumbing in `dsh-agent-loop`. `workspace-context` no longer requests `'raw'`; its self-framed content renders as before. The `renderTagged`/`renderContextEnvelope` helpers are deleted. `context/message.meta` still carries durable, model-hidden JSON state. + +The `source` attribution the envelopes carried is not lost — it remains on the durable events; it simply no longer renders into the transcript. + +## Alternatives considered + +- **Keep the `` envelope, unwrap only steering** — leaves the `ContextEnvelope`/`envelope` machinery alive for a framing bit no model reads, and keeps the inconsistency that the main producer already opts out of. +- **Keep the envelope field for plugin-sourced content only** — splits one projection into two on `source.kind` for no observed benefit; a plugin steering the agent (hook-bridge continuation reasons) also wants the instruction followed, not labeled. +- **Move the unwrapping into adapters** — the canonical projection is the model-visible contract ("model-visible ⟺ logged"); per-adapter divergence on framing would make the derived transcript adapter-dependent. Framing that a caller genuinely wants belongs in the caller's content, not in an adapter. + +## Consequences + +- Mid-turn steering and injected context reach the model with the same weight as an ordinary user prompt. +- The transcript no longer distinguishes injected content from a user message; consumers that need the distinction read the durable event log, which keeps the event types, `source`, and `meta` intact. +- The `hook-{cc,codex}-stop-continue` ACP snapshots were re-recorded: the old recordings captured the model refusing steering as third-party metadata, the fix's exact failure mode. +- The [content-block-vocabulary Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)'s tagged-envelope clause is amended to point here. + +## Deferred + +`workspace-context` already frames its own content: it emits a complete `` block as the message content instead of leaning on a surface-level wrapper. That caller-owned pattern is the one to keep — the surface passes content through verbatim, and any framing lives in the producer's own content. + +Two framing paths existed — caller-baked framing (`workspace-context`'s ``) and surface-level wrapping (``/`` added by `deriveEventMessage`). This change removes the second, leaving only caller-owned framing. If labeled framing is wanted again, unify it through the event's `meta` map — the producer-attached, model-hidden metadata field — consumed by a dedicated renderer or adapter, rather than re-hardcoding a tag in `deriveEventMessage`. A producer declares the frame it wants in `meta`; one renderer applies it; the session-surface projection stays a verbatim pass-through. diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md new file mode 100644 index 0000000000..a01a51e12c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 注入内容逐字投影,去除 XML 封套 + +Status: implemented + +[English](2026-07-20-unwrap-injected-content-envelopes.md) | 中文 + +## 问题 + +两类注入的会话内容在渲染进模型 transcript(文本记录)时被包在 XML 封套里:`steering/message` 包成 ``,`context/message` 包成 ``(后者有一个 `'raw'` 退出选项可跳过封套)。这些封套意在告诉模型「这是注入内容,不是用户在说话」。 + +两个问题: + +- **没有模型在这些标签上训练过。** `` 和 `` 是任何模型都未被教会去读的任意标记,因此这层框架只是徒增 token 而没有可靠效果,还可能起反作用——已录制的 transcript 显示,模型会把 `` 指令当成第三方元数据而拒绝服从,只回答原始提示。 +- **session 表层是承载框架的错误层次。** 表层的职责是把持久日志投影为模型 transcript;决定内容如何措辞并不是它的事。想要特定框架的调用方可以在注入前自行格式化内容——唯一的重度生产方(`workspace-context`)本就这样做,它自带完整的 `` 框架,并用 `envelope: 'raw'` 退出 `` 封套。剩下的标签机制(`ContextEnvelope` 类型,以及贯穿 `InjectOptions`、`HookContext`、`context/message` 事件和 agent loop 的 `envelope` 字段)所服务的区分,本应归属调用方。 + +## 决策 + +注入的会话内容逐字投影,框架由调用方自行负责。`deriveEventMessage` 通过一个共享分支渲染 `user/message`、`context/message` 和 `steering/message`,都返回 `{ role: 'user', content: event.data.content }`;它们的内容块原样到达模型。`context/message` 的 `source`/`meta` 和 `steering/message` 的 `turn` 保留在持久事件日志中,但不渲染。 + +`ContextEnvelope` 类型和所有 `envelope` 字段都被移除——包括 `SessionEventMap` 中的 `context/message`、`InjectOptions`、`HookContext`,以及 `dsh-agent-loop` 中 `inject()`/`additionalContexts` 的相关管线。`workspace-context` 不再请求 `'raw'`;它自带框架的内容渲染方式不变。`renderTagged`/`renderContextEnvelope` 辅助函数被删除。`context/message.meta` 仍携带持久的、对模型隐藏的 JSON 状态。 + +封套曾携带的 `source` 归属并未丢失——它仍保留在持久事件上;只是不再渲染进 transcript。 + +## 权衡的替代方案 + +- **保留 `` 封套,只对 steering 去封套** —— 会为一个没有模型会读的框架位保留 `ContextEnvelope`/`envelope` 机制,并保留主要生产方本就退出的那种不一致。 +- **仅对插件来源的内容保留 envelope 字段** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent(智能体)时(钩子桥接器的轮次续行原因)同样希望指令被遵从,而不是被贴标签。 +- **把去封套的逻辑移入适配器** —— 规范投影就是模型可见契约(「模型可见 ⟺ 已记录」);让各适配器在框架上各行其是,会使派生的 transcript 依赖于适配器。调用方确实想要的框架应放进调用方自己的内容里,而不是适配器。 + +## 结果 + +- 中途引导与注入的 context 以与普通用户提示相同的权重到达模型。 +- transcript 不再区分注入内容与用户消息;需要这一区分的消费方读取持久事件日志,其中事件类型、`source` 和 `meta` 完整保留。 +- `hook-{cc,codex}-stop-continue` ACP 快照已重新录制:旧录制捕获的是模型把 steering 当作第三方元数据而拒绝服从,正是本次修复针对的失败模式。 +- [内容块词汇表 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md) 中关于带标签封套的条款已修订为指向本文。 + +## 推迟事项 + +`workspace-context` 已经自行为内容加框架:它把一个完整的 `` 块作为消息内容发出,而不依赖表层封套。这种调用方自有的模式才是应保留的——表层逐字透传内容,任何框架都住在生产方自己的内容里。 + +曾经存在两条框架路径——调用方自行加框架(`workspace-context` 的 ``),以及表层封套(`deriveEventMessage` 加上的 ``/``)。本次变更移除了后者,只留下调用方自有的框架。如果未来又需要带标签的框架,应由事件的 `meta` map(生产方附加、对模型隐藏的元数据字段)来统一它,交给专门的渲染器或适配器消费,而不是在 `deriveEventMessage` 中重新硬编码标签。生产方在 `meta` 中声明所需的框架,由一个渲染器统一施加;session 表层的投影始终保持逐字透传。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md deleted file mode 100644 index 93be7191a5..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: Project steering messages as plain user content - -Status: implemented - -English | [中文](2026-07-20-unwrap-steering-message-projection.zh.md) - -## Problem - -`Session.deriveEventMessage` rendered `steering/message` inside a `` envelope, mirroring the `context/message` framing. But the two events differ in kind: context injection is ambient, non-conversational material (file-change notices, workspace instructions) where the envelope tells the model "this is not the user speaking", while steering *is* the user (or a plugin acting for the user) speaking mid-turn — "also reply with SECOND", "focus on tests". Wrapping that direction in an XML label distances the model from an instruction it should treat as a first-class user message; recorded transcripts show models reasoning about whether to obey "the steering input" as if it were third-party metadata. - -## Decision - -`steering/message` projects to a plain user-role message carrying its content blocks verbatim — identical to `user/message` projection. The `` envelope on `context/message` (with its `raw` opt-out) is untouched. The former `renderTagged` helper in `packages/core/session/src/index.ts` is now the context-only `renderContextEnvelope` with no tag parameter. The compaction renderer's `[Steering: …]` label is unaffected: that is a summarization-input format, not model-visible history. - -The `source` attribution that the envelope carried is not lost — it remains on the durable `steering/message` event; it just no longer renders into the model transcript. - -## Alternatives considered - -- **Keep the envelope for plugin-sourced steering only** — splits one projection into two on `source.kind` for no observed benefit; a plugin steering the agent (hook-bridge continuation reasons) also wants the instruction followed, not attributed. -- **Move the unwrapping into adapters** — the canonical projection is the model-visible contract ("model-visible ⟺ logged"); per-adapter divergence on framing would make the derived transcript adapter-dependent. - -## Consequences - -- Mid-turn steering reaches the model with the same weight as an ordinary user prompt. -- The transcript no longer distinguishes a steering injection from a user message; consumers that need the distinction read the durable event log, which keeps `steering/message` and its `source` intact. -- The [content-block-vocabulary Agent Note](../architecture/2026-06-11-content-block-vocabulary.md)'s tagged-envelope clause now covers `context/message` only and is amended to point here. diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md deleted file mode 100644 index 8e3fcd0fbf..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-steering-message-projection.zh.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: steering 消息投影为普通用户内容 - -Status: implemented - -[English](2026-07-20-unwrap-steering-message-projection.md) | 中文 - -## 问题 - -`Session.deriveEventMessage` 曾把 `steering/message` 包在 `` 封套里渲染,与 `context/message` 的框架保持一致。但这两类事件性质不同:上下文注入是环境性的、非对话性的材料(文件变更通知、工作区指令),封套告诉模型「这不是用户在说话」;而 steering(中途引导)恰恰**是**用户(或代表用户的插件)在轮次中途发言——「再回复 SECOND」「专注于测试」。把这种指令包进 XML 标签会让模型把本应作为一等用户消息对待的指令当成第三方元数据;已录制的 transcript(文本记录)显示,模型会推理是否要服从「那条 steering 输入」,仿佛它是旁观者的附注。 - -## 决策 - -`steering/message` 投影为普通的 user 角色消息,逐字携带其内容块——与 `user/message` 的投影完全相同。`context/message` 上的 `` 封套(及其 `raw` 退出选项)保持不变。`packages/core/session/src/index.ts` 中原来的 `renderTagged` 辅助函数现在是只服务于 context 的 `renderContextEnvelope`,不再接受标签参数。压缩(compaction)渲染器的 `[Steering: …]` 标注不受影响:那是摘要输入格式,不是模型可见的历史。 - -封套曾携带的 `source` 归属并未丢失——它仍保留在持久的 `steering/message` 事件上;只是不再渲染进模型 transcript。 - -## 备选方案 - -- **仅对插件来源的 steering 保留封套** —— 会按 `source.kind` 把一条投影拆成两条,却没有观察到任何收益;插件引导 agent(智能体)时(钩子桥接器的轮次续行原因)同样希望指令被遵从,而不是被归因。 -- **把去封套的逻辑移入适配器** —— 规范投影就是模型可见契约(「模型可见 ⟺ 已记录」);让各适配器在框架上各行其是,会使派生的 transcript 依赖于适配器。 - -## 影响 - -- 中途引导以与普通用户提示相同的权重到达模型。 -- transcript 不再区分 steering 注入与用户消息;需要这一区分的消费方读取持久事件日志,其中 `steering/message` 及其 `source` 完整保留。 -- [内容块词汇表 Agent Note](../architecture/2026-06-11-content-block-vocabulary.md) 中关于带标签封套的条款现在只覆盖 `context/message`,并已修订为指向本文。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..82d3ecc883 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:152`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:210`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:222`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:161`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d7c208510..8472431209 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -820,7 +820,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:574`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:549`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b87513e11..5b3d5135b2 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -338,13 +338,11 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata: +`InjectOptions` extends ordinary message attribution with durable model-hidden JSON metadata: ```ts type-equiv /** Options specific to durable synthetic context injection. */ interface InjectOptions extends SendOptions { - /** Keep the canonical context tag, or send caller-owned framing verbatim. */ - envelope?: ContextEnvelope /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } @@ -407,7 +405,7 @@ The process-local initiator carried by `ctx.agents` is the exact `Agent` above, ## Interception decisions -Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its optional `envelope` selects the canonical context tag or caller-owned raw framing, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, framing, and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its `content` reaches the model verbatim as a user-role message, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -416,8 +414,6 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types interface HookContext { content: ContentBlock[] source: MessageSource - /** Keep the canonical context tag, or use caller-owned framing verbatim. */ - envelope?: ContextEnvelope /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } @@ -436,7 +432,7 @@ type PromptDecision = | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): +`agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context metadata — the typed `/goal` pattern): ```ts type-equiv /** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1b9dfb1a9e..502da9d00d 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -4,15 +4,6 @@ The in-memory, event-sourced model of [dsh-session](../../packages/core/session) Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) -## Context framing - -`ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history. - -```ts type-equiv -/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ -type ContextEnvelope = 'context' | 'raw' -``` - ## `SessionEventMap` — the event vocabulary The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. @@ -52,14 +43,17 @@ interface SessionEventMap { /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller - * own the complete model-facing frame; `meta` is durable JSON state omitted - * from the model projection. + * as a synthetic user-role message carrying `content` verbatim — NOT a + * user prompt. `meta` is durable JSON state omitted from the model + * projection; it is also the intended channel for any future framing + * directive (a producer declares the frame, a dedicated renderer applies it — + * see the deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. */ 'context/message': { content: ContentBlock[] source: MessageSource - envelope?: ContextEnvelope meta?: JsonValue } /** Raw stream chunk — token-level replay fidelity. */ @@ -432,7 +426,7 @@ declare class Session { - `user/message` → a user message. - `assistant/message` → an assistant message with the event's provider/model provenance and optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its usage/provenance, but a content-less assistant turn must not enter the provider transcript. - `tool/result` → a user message carrying a `tool-result` block. -- `context/message` → a user-role message at its chronological position. The default `envelope` is `context`, which wraps content as ``; `envelope: 'raw'` uses caller-owned framing verbatim. Optional JSON `meta` remains in the event log and is never rendered. +- `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying its content verbatim at its chronological position. Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 30ec86213a..9e3d698440 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -180,8 +180,8 @@ A tool body receives the runtime extension. `deferContext()` is the composite-to interface ToolRunContext extends ToolExecution { /** * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source, envelope, and - * metadata and are emitted in call order. + * the agent loop. Contexts retain their individual source and metadata and + * are emitted in call order. */ deferContext(context: HookContext): void } diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..357a8c5aa5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:143`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:152`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:307`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:210`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:222`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:274`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:184`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:161`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 688d3b545c..8675797290 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -244,21 +244,24 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller - * own the complete model-facing frame; `meta` is durable JSON state omitted - * from the model projection. + * as a synthetic user-role message carrying `content` verbatim — NOT a + * user prompt. `meta` is durable JSON state omitted from the model + * projection; it is also the intended channel for any future framing + * directive (a producer declares the frame, a dedicated renderer applies it — + * see the deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. */ 'context/message': { content: ContentBlock[] source: MessageSource - envelope?: ContextEnvelope meta?: JsonValue } ``` Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `hook/*` @@ -336,7 +339,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts) ### `request/*` @@ -374,7 +377,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +386,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) ### `todo/*` @@ -474,7 +477,7 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -490,7 +493,7 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:184`](../packages/core/session/src/types.ts) ### `user/*` @@ -503,4 +506,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index f744c1b000..a20dbcd999 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -86,7 +86,7 @@ {"type":"tool/call","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} {"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} {"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[84],"surfaceOp":"append"} -{"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":88,"time":1783921767272,"data":{"turn":1,"step":1}} {"type":"step/start","seq":89,"time":1783921767272,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":90,"time":1783921768339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 94ea8a8fec..dc9e4a844a 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 321c5499a2..e5fd8608d6 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,7 +1,7 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index b133708b9a..f50e124b32 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/message","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} {"type":"tool/result","seq":11,"time":1783778297072,"data":{"turn":1,"step":1,"callId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} -{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"context/message","seq":12,"time":1783778297072,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783778297072,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783778297072,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index a04c844e9b..7dd1e06931 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. -The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `` envelope. +The plugin owns the complete `` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping. ## State And Refresh diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index 83bbfa9d26..21ef979459 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -92,7 +92,6 @@ export function apply(ctx: Context, config: Config): void { if (update !== undefined) { agent.inject(update.context.content, { source: update.context.source, - envelope: update.context.envelope, meta: update.context.meta, }) applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions) diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index 34e427d0e3..910853b13b 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -163,6 +163,12 @@ function buildInstructionText( ): string { const marker = markerText(maxBytes, omitted, truncated) const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0) + // Caller-owned framing: the plugin bakes the complete `` + // frame into the message content. The session surface projects context + // verbatim and does not wrap it, so any framing must live here in the + // producer's content (the pattern a future `meta`-driven renderer would + // generalize — see the deferred note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 4e8ee7de56..f2e113ddfe 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -61,9 +61,8 @@ export interface ReconciledInstructionContext { versionUpdates: InstructionVersionUpdate[] } -/** Plugin-owned raw context with required replay metadata. */ +/** Plugin-owned context with required replay metadata. */ export interface WorkspaceHookContext extends HookContext { - envelope: 'raw' meta: JsonValue } @@ -76,7 +75,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[ ...change.digest !== undefined ? { digest: change.digest } : {}, })) const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges } - return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta } + return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta } } /** diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 2ff2067cf5..8b0320bcfc 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -173,7 +173,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('context/message', { content, source: options?.source ?? { kind: 'user' }, - ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, ...options?.meta !== undefined ? { meta: options.meta } : {}, }, { surfaceOp: 'append' }) }, @@ -202,7 +201,6 @@ function workspaceChangeContext(scope: string, digest: string): HookContext { return { content: [{ type: 'text', text: `instructions for ${scope}` }], source: { kind: 'plugin', plugin: 'workspace-context' }, - envelope: 'raw', meta: { kind: 'workspace-instructions', version: 1, @@ -217,7 +215,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H lastSeq = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }).seq } @@ -1695,7 +1692,6 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', version: 1, @@ -2461,7 +2457,6 @@ describe('dynamic nested workspace context injection', () => { expect(blocksText(result.content)).toBe('downstream replacement') expect(result.additionalContexts).toHaveLength(2) expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' }) - expect(workspaceContextOf(result)?.envelope).toBe('raw') expect(workspaceContextOf(result)?.meta).toMatchObject({ kind: 'workspace-instructions', changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }], @@ -2474,7 +2469,8 @@ describe('dynamic nested workspace context injection', () => { }) const agent = stubAgent(root) appendAdditionalContexts(agent, result) - expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('\ndownstream context\n') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context') + expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain(' { const otherWorkspaceEvent = agent.session.append('context/message', { content: otherContext.content, source: otherContext.source, - ...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {}, ...otherContext.meta !== undefined ? { meta: otherContext.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions) @@ -2817,7 +2812,6 @@ describe('workspace context pending state', () => { const confirmed = agent.session.append('context/message', { content: context.content, source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }, { surfaceOp: 'append' }) observeInstructionSessionEvent(agent.session, confirmed, pending, versions) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..f365223aff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1068,10 +1068,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, - { - name: 'ContextEnvelope', - declaration: 'export type ContextEnvelope = \'context\' | \'raw\';', - }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', @@ -1170,11 +1166,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', + declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}', }, { name: 'InjectOptions', - declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}', + declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}', }, { name: 'JsonValue', @@ -1258,7 +1254,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource; /* …truncated — full shape in source */', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */', }, { name: 'SessionEventReadRequest', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..a9c7f3b766 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -252,7 +252,6 @@ export class ReactLoopAgent implements Agent { const context = { content, source, - ...options?.envelope !== undefined ? { envelope: options.envelope } : {}, ...options?.meta !== undefined ? { meta: options.meta } : {}, } if (isTurnOpen(this.session)) { diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 9016c16d9b..bc18ee52d3 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -260,11 +260,10 @@ async function runTurn( session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) // Every `allow.additionalContexts` entry is a separate context/message the // next request also sees. The turn is open, so inject() appends each one - // into THIS turn without flattening provenance, framing, or metadata. + // into THIS turn without flattening provenance or metadata. for (const context of decision.additionalContexts ?? []) { agent.inject(context.content, { source: context.source, - ...context.envelope !== undefined ? { envelope: context.envelope } : {}, ...context.meta !== undefined ? { meta: context.meta } : {}, }) } diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 6c0757b460..92281cde94 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -99,7 +99,6 @@ describe('agent/prompt-submit', () => { additionalContexts: [{ content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' }, - envelope: 'raw', meta, }], })) @@ -113,7 +112,6 @@ describe('agent/prompt-submit', () => { expect(userMsg).toBeDefined() expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }]) expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) - expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw') expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') @@ -556,7 +554,6 @@ describe('tool additionalContexts buffering across a step', () => { additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' }, - envelope: 'raw', meta: { callId: exec.callId }, }], })) @@ -580,7 +577,6 @@ describe('tool additionalContexts buffering across a step', () => { .map(b => (b.type === 'text' ? b.text : '')) expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2']) const contextEvents = events(agent).filter(e => e.type === 'context/message') - expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw']) expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }]) }) @@ -591,7 +587,7 @@ describe('tool additionalContexts buffering across a step', () => { name: 'composite', description: 'composite', parameters: {}, async execute(_args, exec) { exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } }) - exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } }) return [{ type: 'text', text: 'outer result' }] }, })) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c58479609b..5652f0715e 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -385,10 +385,10 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) const flat = JSON.stringify(adapter.requests[0]!.messages) expect(flat).toContain('file changed: a.ts') - expect(flat).toContain('') + expect(flat).not.toContain(' { + it('inject() persists structured context content verbatim with durable hidden meta', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' }) @@ -401,14 +401,13 @@ describe('agent loop', () => { agent.inject([{ type: 'text', text }], { source: { kind: 'plugin', plugin: 'workspace-context' }, - envelope: 'raw', meta, }) send(agent, 'go') await waitForIdle(ctx, agent) const contextEvent = agent.session.events.find(event => event.type === 'context/message') - expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta }) + expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta }) const requestText = JSON.stringify(adapter.requests[0]!.messages) expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md') expect(requestText).not.toContain(' { const first = { type: 'text' as const, text: 'mid-turn notice' } agent.inject([first], { source: { kind: 'plugin', plugin: 'x' }, - envelope: 'raw', meta, }) first.text = 'mutated after inject' @@ -458,7 +456,6 @@ describe('agent loop', () => { expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({ - envelope: 'raw', meta, }) expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : [])) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c2b46f210d..08e2fa1112 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -46,7 +46,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). -`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. +`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). @@ -56,7 +56,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle -- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). +- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 702861f407..ebe1e503af 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' -import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -32,8 +32,6 @@ export interface SendOptions { /** Options specific to durable synthetic context injection. */ export interface InjectOptions extends SendOptions { - /** Keep the canonical context tag, or send caller-owned framing verbatim. */ - envelope?: ContextEnvelope /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } @@ -50,8 +48,6 @@ export type AgentStatus = 'idle' | 'running' | 'disposed' export interface HookContext { content: ContentBlock[] source: MessageSource - /** Keep the canonical context tag, or use caller-owned framing verbatim. */ - envelope?: ContextEnvelope /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } diff --git a/packages/core/session/README.md b/packages/core/session/README.md index d164a8ffc8..ceae25f3a3 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -56,7 +56,7 @@ Durable values need one accepted representation, not a check followed by a secon `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. +`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. ### Session event vocabulary (`types.ts`) @@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) #### What the model sees -The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. #### Token effect diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 19b78cde47..99024999e9 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' +import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' @@ -80,19 +80,6 @@ declare module 'cordis' { } } -/** - * Render injected context as tagged synthetic user-role content, keeping the - * canonical session vocabulary provider-neutral. Adapter-specific exceptions - * belong in the adapter. - */ -function renderContextEnvelope(content: ContentBlock[], source: MessageSource): ContentBlock[] { - return [ - { type: 'text', text: `` }, - ...content, - { type: 'text', text: '' }, - ] -} - /** Detach, validate, and freeze the creation metadata published by a session. */ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader { const input: unknown = source === undefined @@ -226,22 +213,6 @@ interface SessionEntry { /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */ const attachments = new WeakMap() -/** - * Render one context contribution exactly as it will appear in model history. - * @param content - content blocks supplied by the context producer. - * @param source - attribution used by the canonical context envelope. - * @param envelope - canonical tagged framing or caller-owned raw framing. - * @returns a detached block list ready for the derived model transcript. - */ -export function renderContextContent( - content: ContentBlock[], - source: MessageSource, - envelope: ContextEnvelope = 'context', -): ContentBlock[] { - const cloned = structuredClone(content) - return envelope === 'raw' ? cloned : renderContextEnvelope(cloned, source) -} - /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * @@ -507,7 +478,18 @@ export class Session { // trace/replay data. switch (event.type) { - case 'user/message': { + // Injected context and mid-turn steering project identically to a user + // prompt: content verbatim, in user role. context's `source`/`meta` and + // steering's `turn` are log-only and do not reach the model. Do NOT + // re-add per-type framing (e.g. ``/``) here: framing is + // caller-owned — a producer bakes it into `content`, as workspace-context + // does with `` — or, if reintroduced, must be driven by + // the event `meta` map and a dedicated renderer, keeping this projection a + // verbatim pass-through. See the deferred design note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md + case 'user/message': + case 'context/message': + case 'steering/message': { return { role: 'user', content: event.data.content } } case 'assistant/message': { @@ -524,13 +506,6 @@ export class Session { content: [{ type: 'tool-result', toolCallId: callId, content, isError }], } } - case 'context/message': { - const { content, source, envelope } = event.data - return { role: 'user', content: renderContextContent(content, source, envelope) } - } - case 'steering/message': { - return { role: 'user', content: event.data.content } - } default: // A non-surface event (boundary, chunk, log-only record) projects to // no message. Merge-extensible union: no assertNever here. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index dc34760e9c..50950e3f6b 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -2,9 +2,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' import type { JsonValue } from './json.ts' -/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ -export type ContextEnvelope = 'context' | 'raw' - /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -205,14 +202,17 @@ export interface SessionEventMap { /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history - * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller - * own the complete model-facing frame; `meta` is durable JSON state omitted - * from the model projection. + * as a synthetic user-role message carrying `content` verbatim — NOT a + * user prompt. `meta` is durable JSON state omitted from the model + * projection; it is also the intended channel for any future framing + * directive (a producer declares the frame, a dedicated renderer applies it — + * see the deferred note in + * ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), + * so the surface keeps projecting `content` verbatim rather than wrapping it. */ 'context/message': { content: ContentBlock[] source: MessageSource - envelope?: ContextEnvelope meta?: JsonValue } /** Raw stream chunk — token-level replay fidelity. */ diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 93bfe132cd..588c8048f4 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -48,7 +48,7 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) - it('renders context messages tagged and steering messages as plain user content', () => { + it('renders context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) session.append('context/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], @@ -62,13 +62,12 @@ describe('Session', () => { const [contextMessage, steeringMessage] = session.deriveMessages() expect(contextMessage!.role).toBe('user') - expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '' }) - expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '' }) + expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }]) expect(steeringMessage!.role).toBe('user') expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }]) }) - it('renders raw context without a generic envelope while preserving structured metadata', () => { + it('keeps context meta durable in the event while hiding it from the projection', () => { const session = new Session(SessionId('s2-raw')) const meta = { kind: 'workspace-instructions', @@ -78,7 +77,6 @@ describe('Session', () => { session.append('context/message', { content: [{ type: 'text', text: 'Additional instructions from: pkg/AGENTS.md' }], source: { kind: 'plugin', plugin: 'workspace-context' }, - envelope: 'raw', meta, }, { surfaceOp: 'append' }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 3d983f1501..fc8ebfcd10 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -369,7 +369,7 @@ describe('deriveMessages with surface', () => { s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const messages = s.deriveMessages() expect(messages).toHaveLength(2) - expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '' }) + expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }]) expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }]) }) }) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 6f0959c54a..2cb841d0e2 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -38,7 +38,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. - `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. +- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. @@ -108,7 +108,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails. +- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. ### Parallel execution diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 4e7ae12898..d7299aa0be 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -235,8 +235,8 @@ export interface ToolExecution extends ToolExecutionInput { export interface ToolRunContext extends ToolExecution { /** * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source, envelope, and - * metadata and are emitted in call order. + * the agent loop. Contexts retain their individual source and metadata and + * are emitted in call order. */ deferContext(context: HookContext): void } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index cfe317ef02..227ff98129 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -487,7 +487,6 @@ describe('the run_code dispatch bridge', () => { additionalContexts: [{ content: [{ type: 'text' as const, text: `context for ${exec.callId}` }], source: { kind: 'plugin' as const, plugin: 'test' }, - envelope: 'raw' as const, meta: { callId: exec.callId }, }], }) @@ -505,13 +504,11 @@ describe('the run_code dispatch bridge', () => { { content: [{ type: 'text', text: 'context for call-1:code:1' }], source: { kind: 'plugin', plugin: 'test' }, - envelope: 'raw', meta: { callId: 'call-1:code:1' }, }, { content: [{ type: 'text', text: 'context for call-1:code:2' }], source: { kind: 'plugin', plugin: 'test' }, - envelope: 'raw', meta: { callId: 'call-1:code:2' }, }, ]) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index b936887bc9..6c35f4f549 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -384,7 +384,7 @@ describe('ToolRegistry', () => { parameters: {}, async execute(_args, exec) { exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } }) - exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' }) + exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } }) return [{ type: 'text', text: 'done' }] }, })) @@ -418,7 +418,6 @@ describe('ToolRegistry', () => { { kind: 'plugin', plugin: 'post' }, ]) expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 }) - expect(result.additionalContexts?.[1]?.envelope).toBe('raw') }) it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => { diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 30944c2254..ef5e4e5846 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source, envelope, and metadata. +Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata. ## Testing diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index dc9a047a4f..0c630686b4 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -140,7 +140,7 @@ function validateThresholds(values: number[]): number[] { /** * Prepend the guard's reminder while preserving every downstream context's - * source, envelope, and metadata. + * source and metadata. */ function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] { return [ours, ...theirs ?? []] diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index ec7d18b4c4..870d369784 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -508,7 +508,6 @@ export function defineCoverageCases(group: CoverageGroup): void { additionalContexts: [{ content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' }, - envelope: 'raw' as const, meta: { owner: 'policy' }, }], })) @@ -527,7 +526,6 @@ export function defineCoverageCases(group: CoverageGroup): void { { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) @@ -561,7 +559,6 @@ export function defineCoverageCases(group: CoverageGroup): void { additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream-note' }], source: { kind: 'plugin' as const, plugin: 'policy' }, - envelope: 'raw' as const, meta: { owner: 'policy' }, }], })) @@ -574,7 +571,6 @@ export function defineCoverageCases(group: CoverageGroup): void { { kind: 'plugin', plugin: 'hooks-claude' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index a7f2e1e0ac..e02ea52df1 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -125,7 +125,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro additionalContexts: [{ content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' }, - envelope: 'raw' as const, meta: { owner: 'policy' }, }], })) @@ -140,7 +139,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) }) @@ -171,7 +169,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro additionalContexts: [{ content: [{ type: 'text' as const, text: 'downstream-note' }], source: { kind: 'plugin' as const, plugin: 'policy' }, - envelope: 'raw' as const, meta: { owner: 'policy' }, }], })) @@ -183,7 +180,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro { kind: 'plugin', plugin: 'hooks-codex' }, { kind: 'plugin', plugin: 'policy' }, ]) - expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw') expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..3233dcca26 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -41,7 +41,6 @@ { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "ContextEnvelope", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..d9f6d2ca24 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L143) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L152) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L307) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L260) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L200) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L210) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L171) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L222) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L274) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L237) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L184) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L161) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L248) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L284) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L294) ## agent-loop/* diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index af43a0227c..02a04611c2 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L574) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L549) ### ctx.sessions.create(id?, options?) @@ -44,7 +44,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L603) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L578) ### ctx.sessions.prepare(id?, options?) @@ -75,7 +75,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L632) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L607) ### ctx.sessions.enter(session) @@ -112,7 +112,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L676) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L651) ### ctx.sessions.announce(session) @@ -131,7 +131,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L731) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L706) ### ctx.sessions.flush(session) @@ -156,7 +156,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L783) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L758) ### ctx.sessions.get(id) @@ -175,7 +175,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L815) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L790) ### ctx.sessions.list() @@ -191,7 +191,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L823) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L798) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -220,4 +220,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L840) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L815) From 2b1b59846751381322867e997ddb2024d001923e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 16:32:08 +0800 Subject: [PATCH 080/132] docs: restore generated Cordis core API --- ...-07-20-generated-cordis-core-api.i18n.yaml | 6 + .../2026-07-20-generated-cordis-core-api.md | 31 ++ ...2026-07-20-generated-cordis-core-api.zh.md | 31 ++ docs/AGENTS.md | 2 +- docs/cordis-catalog/core/context.md | 364 +++++++++++++++ docs/cordis-catalog/core/events.md | 207 +++++++++ docs/cordis-catalog/core/fiber.md | 375 +++++++++++++++ docs/cordis-catalog/core/registry.md | 152 ++++++ docs/cordis-catalog/core/service.md | 102 +++++ docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- scripts/cordis-core-api.spec.ts | 49 ++ scripts/cordis-core-api.ts | 433 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 20 +- scripts/project-doc-site.spec.ts | 12 + website/.vitepress/config.ts | 2 + website/docs.ts | 15 + 17 files changed, 1795 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md create mode 100644 docs/cordis-catalog/core/context.md create mode 100644 docs/cordis-catalog/core/events.md create mode 100644 docs/cordis-catalog/core/fiber.md create mode 100644 docs/cordis-catalog/core/registry.md create mode 100644 docs/cordis-catalog/core/service.md create mode 100644 scripts/cordis-core-api.spec.ts create mode 100644 scripts/cordis-core-api.ts diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml new file mode 100644 index 0000000000..6bae3b4d87 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-generated-cordis-core-api.md: 848dec2dba6f432c706798c40abe98e8937da651 +2026-07-20-generated-cordis-core-api.zh.md: c40a480224f4e1387b71ade9264458cd84403584 diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md new file mode 100644 index 0000000000..848dec2dba --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md @@ -0,0 +1,31 @@ +# Agent Note: Generate the Cordis core API reference + +Status: implemented + +English | [中文](2026-07-20-generated-cordis-core-api.zh.md) + +## Problem + +Plugin authors need the detailed Cordis APIs behind `ctx`, event dispatch, fibers, plugin registration, and services. The generated [Harness event and service catalogs](2026-06-20-generated-cordis-catalog.md) intentionally summarize inherited Cordis members, so they do not replace a method-level Cordis reference. Keeping a second hand-written copy under the website would drift from the vendored source and make the renderer an additional documentation owner. + +## Decision + +`scripts/cordis-core-api.ts` reads the public declarations and original JSDoc from `vendor/cordis/src` with the TypeScript compiler API. An explicit page manifest generates five files under [`docs/cordis-catalog/core/`](../../../../docs/cordis-catalog/core/context.md): Context, Events, Fiber, Registry, and Service. `scripts/gen-cordis-catalog.ts` writes these pages together with the Harness event and service catalogs, and `verify-cordis-catalog` rejects stale output. + +The generator validates that documented classes and methods retain descriptive JSDoc, including parameter and non-void return contracts. It emits declaration-only `ts cordis-catalog` fences with the original JSDoc, then renders the same description, parameters, and return contract as readable Markdown. Source links point to the vendored files, and the five pages cross-link to one another. The Harness catalogs remain the exhaustive inventory of repository-declared events and `ctx.*` services; the core pages document how the inherited Cordis APIs operate. + +`website/docs.ts` publishes the five canonical files under matching `/reference/cordis-api/` and `/en/reference/cordis-api/` routes. Both locales use the English generated source until the generator emits translated pages, so changing language preserves navigation structure and route identity. + +## Alternatives considered + +**Restore the old website files as canonical Markdown.** This would recover the pages quickly, but their signatures and prose could drift from the vendored implementation and the website would regain a second documentation source. + +**Expand the inherited tier of the Harness catalogs in place.** Those catalogs answer which Harness events and services exist. Mixing full framework class references into the same pages would obscure that inventory and reverse their deliberate terse inherited tier. + +**Publish vendored source declarations directly.** Source files are authoritative but do not provide stable topic pages, curated public ordering, or website navigation, and they expose implementation bodies that are not part of the reference contract. + +## Consequences + +The five Cordis API pages follow vendor updates through one deterministic generator and share the repository's documentation freshness gate. The website gains a dedicated Cordis API section without copied site content, while root and English navigation remain structurally identical. + +The page manifest is curated, so a newly public Cordis core type needs an explicit generator entry. Generated prose is English-only, and source JSDoc quality directly limits reference quality; Chinese output requires generator-level translation rather than hand-editing the generated files. diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md new file mode 100644 index 0000000000..c40a480224 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 生成 Cordis 核心 API 参考文档 + +Status: implemented + +[English](2026-07-20-generated-cordis-core-api.md) | 中文 + +## 问题 + +插件作者需要了解 `ctx`、事件派发、Fiber、插件注册和 Service 背后的详细 Cordis API。已有的 [Harness 事件与服务目录](2026-06-20-generated-cordis-catalog.md)有意只简要概括继承自 Cordis 的成员,因此无法替代方法级 Cordis 参考文档。如果在网站下维护另一份手写副本,它会与 vendored 源码产生漂移,也会让渲染器成为额外的文档所有者。 + +## 决策 + +`scripts/cordis-core-api.ts` 使用 TypeScript Compiler API,从 `vendor/cordis/src` 读取公开声明和原始 JSDoc。一个显式页面清单在 [`docs/cordis-catalog/core/`](../../../../docs/cordis-catalog/core/context.md) 下生成五个文件:Context、Events、Fiber、Registry 和 Service。`scripts/gen-cordis-catalog.ts` 将这些页面与 Harness 事件和服务目录一同写入,`verify-cordis-catalog` 会拒绝过期产物。 + +生成器会验证所记录的类和方法保留描述性 JSDoc,包括参数和非 void 返回值契约。它生成包含原始 JSDoc 且仅含声明的 `ts cordis-catalog` 代码围栏,再将同一份说明、参数和返回值契约渲染为便于阅读的 Markdown。源码链接指向 vendored 文件,五个页面之间相互交叉链接。Harness 目录仍是仓库声明的事件与 `ctx.*` 服务的完整清单;核心页面负责说明继承自 Cordis 的 API 如何工作。 + +`website/docs.ts` 将五个规范源文件发布到结构对应的 `/reference/cordis-api/` 和 `/en/reference/cordis-api/` 路由。在生成器产出翻译页面之前,两个 locale 都使用英文生成源,因此切换语言时导航结构和路由标识保持不变。 + +## 考虑过的替代方案 + +**将旧网站文件恢复为规范 Markdown。** 这能快速恢复页面,但其签名和说明可能与 vendored 实现漂移,网站也会重新成为第二个文档来源。 + +**直接扩充 Harness 目录中的继承层。** 这些目录回答有哪些 Harness 事件与服务。将完整的框架类参考混入同一页面会模糊这份清单的定位,并推翻继承层保持精简的既有决定。 + +**直接发布 vendored 源码声明。** 源文件具有权威性,但不能提供稳定的主题页面、经过筛选的公开顺序或网站导航,还会暴露不属于参考契约的实现体。 + +## 影响 + +五个 Cordis API 页面通过同一个确定性生成器跟随 vendor 更新,并复用仓库的文档新鲜度检查。网站无需复制内容即可获得独立的 Cordis API 章节,中文入口和英文入口的导航结构保持一致。 + +页面清单需要人工维护,因此新增公开 Cordis 核心类型时必须显式添加生成器条目。当前生成说明只有英文,且源码 JSDoc 的质量直接决定参考文档质量;中文产物需要在生成器层实现翻译,不能手工编辑生成文件。 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index f33110d9b0..da0c03b088 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -18,7 +18,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts | -| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | +| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [Cordis core API](cordis-catalog/core/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. diff --git a/docs/cordis-catalog/core/context.md b/docs/cordis-catalog/core/context.md new file mode 100644 index 0000000000..f6b249c738 --- /dev/null +++ b/docs/cordis-catalog/core/context.md @@ -0,0 +1,364 @@ + + +# Context + +The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md). + +Root and child dependency containers for Cordis plugins. + +A context is a proxy: normal property reads go through the service resolver, while `extend()`, `isolate()`, and `intercept()` create scoped child contexts without mutating their parent. + +[Source](../../../vendor/cordis/src/context.ts#L42) + +### ctx.extend(meta?) + +```ts cordis-catalog +/** + * Create a child context with extra metadata on top of the current scope. + * + * The child prototypally inherits every property of this context; own + * properties of `meta` shadow the inherited ones. The parent is not mutated. + * + * @param meta — own properties (including symbol keys) to define on the child. + * @returns a child context inheriting from this one. + */ +extend(meta = {}): this +``` + +Create a child context with extra metadata on top of the current scope. + +The child prototypally inherits every property of this context; own properties of `meta` shadow the inherited ones. The parent is not mutated. + +- `meta` — own properties (including symbol keys) to define on the child. + +**Returns** a child context inheriting from this one. + +[Source](../../../vendor/cordis/src/context.ts#L99) + +### ctx.isolate(name, label?) + +```ts cordis-catalog +/** + * Create a child context with an independent service scope for `name`. + * + * Below the returned context, reads and writes of the service `name` + * resolve against the new label instead of the parent's, so a different + * implementation can be provided without affecting the parent scope. + * Passing the same `label` to two `isolate()` calls joins their scopes. + * + * @param name — the service name to isolate. + * @param label — scope label to join; defaults to a fresh unique symbol. + * @returns a child context whose `name` service resolves in the new scope. + */ +isolate(name: string, label?: symbol) +``` + +Create a child context with an independent service scope for `name`. + +Below the returned context, reads and writes of the service `name` resolve against the new label instead of the parent's, so a different implementation can be provided without affecting the parent scope. Passing the same `label` to two `isolate()` calls joins their scopes. + +- `name` — the service name to isolate. +- `label` — scope label to join; defaults to a fresh unique symbol. + +**Returns** a child context whose `name` service resolves in the new scope. + +[Source](../../../vendor/cordis/src/context.ts#L121) + +### ctx.intercept(name, config) + +```ts cordis-catalog +/** + * Add service-specific intercept config for plugins started below this + * context. + * + * Plugins loaded under the returned context see `config` merged into the + * service's resolved config (ancestor entries first; see + * `Service[symbols.resolveConfig]`). The parent context is not affected. + * + * @param name — the service name whose config to intercept. + * @param config — the intercept config to merge for that service. + * @returns a child context carrying the additional intercept entry. + */ +intercept(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this +intercept(name: string, config: any): this +``` + +Add service-specific intercept config for plugins started below this context. + +Plugins loaded under the returned context see `config` merged into the service's resolved config (ancestor entries first; see `Service[symbols.resolveConfig]`). The parent context is not affected. + +- `name` — the service name whose config to intercept. +- `config` — the intercept config to merge for that service. + +**Returns** a child context carrying the additional intercept entry. + +[Source](../../../vendor/cordis/src/context.ts#L139) + +### ctx.root + +```ts cordis-catalog +/** The root context of the application (every child context shares it). @experimental */ +root: this +``` + +The root context of the application (every child context shares it). @experimental + +[Source](../../../vendor/cordis/src/context.ts#L22) + +### ctx.baseUrl + +```ts cordis-catalog +/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ +baseUrl?: string +``` + +Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. + +[Source](../../../vendor/cordis/src/context.ts#L24) + +### ctx.events + +```ts cordis-catalog +/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */ +events: EventsService +``` + +The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). + +[Source](../../../vendor/cordis/src/context.ts#L26) + +### ctx.logger + +```ts cordis-catalog +/** The logging service. Call `ctx.logger(name)` for a named logger. */ +logger: LoggerService +``` + +The logging service. Call `ctx.logger(name)` for a named logger. + +[Source](../../../vendor/cordis/src/context.ts#L28) + +### ctx.reflect + +```ts cordis-catalog +/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */ +reflect: ReflectService +``` + +The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). + +[Source](../../../vendor/cordis/src/context.ts#L30) + +### ctx.registry + +```ts cordis-catalog +/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */ +registry: RegistryService +``` + +The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). + +[Source](../../../vendor/cordis/src/context.ts#L32) + +## Static members + +### Context.effect + +```ts cordis-catalog +/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */ +static readonly effect: unique symbol +``` + +Symbol key under which a disposer exposes its EffectMeta diagnostics tree. + +[Source](../../../vendor/cordis/src/context.ts#L44) + +### Context.filter + +```ts cordis-catalog +/** Symbol key for a context's listener filter, consulted on every event dispatch. */ +static readonly filter: unique symbol +``` + +Symbol key for a context's listener filter, consulted on every event dispatch. + +[Source](../../../vendor/cordis/src/context.ts#L46) + +### Context.isolate + +```ts cordis-catalog +/** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */ +static readonly isolate: unique symbol +``` + +Symbol key of the isolation map (see the `Context[symbols.isolate]` property). + +[Source](../../../vendor/cordis/src/context.ts#L48) + +### Context.intercept + +```ts cordis-catalog +/** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */ +static readonly intercept: unique symbol +``` + +Symbol key of the intercept map (see the `Context[symbols.intercept]` property). + +[Source](../../../vendor/cordis/src/context.ts#L50) + +### Context.is(value) + +```ts cordis-catalog +/** + * Returns true for Cordis context proxies and context prototypes. + * + * Works across realms and across multiple copies of cordis, because the + * brand is keyed by a global symbol rather than by `instanceof`. + * + * @param value — the value to test. + * @returns `true` if `value` is a Cordis context, narrowing its type. + */ +static is(value: any): value is Context +``` + +Returns true for Cordis context proxies and context prototypes. + +Works across realms and across multiple copies of cordis, because the brand is keyed by a global symbol rather than by `instanceof`. + +- `value` — the value to test. + +**Returns** `true` if `value` is a Cordis context, narrowing its type. + +[Source](../../../vendor/cordis/src/context.ts#L61) + +## Service store and mixins + +### ctx.get(name, strict?) + +```ts cordis-catalog +/** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true` (default), only return implementations + * whose providing fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ +get(name: K, strict?: boolean): undefined | this[K] +get(name: string, strict?: boolean): any +``` + +Read a service from the store without the inject requirement. + +- `name` — the service name. +- `strict` — when `true` (default), only return implementations whose providing fiber is currently active. + +**Returns** the service value, or `undefined` when not (yet) provided. + +[Source](../../../vendor/cordis/src/reflect.ts#L16) + +### ctx.set(name, value) + +```ts cordis-catalog +/** + * Overwrite a provided service's value. + * + * Only the fiber that provided the service may set it; setting an + * unprovided name throws. + * + * @param name — the service name. + * @param value — the new service value. + */ +set(name: K, value: undefined | this[K]): void +set(name: string, value: any): void +``` + +Overwrite a provided service's value. + +Only the fiber that provided the service may set it; setting an unprovided name throws. + +- `name` — the service name. +- `value` — the new service value. + +[Source](../../../vendor/cordis/src/reflect.ts#L28) + +### ctx.provide(name, value) + +```ts cordis-catalog +/** + * Register a service implementation owned by the current fiber. + * + * The service becomes visible to dependents in the same isolation scope + * once the fiber is active; it is unregistered (waking dependents) when + * the returned disposer runs or the fiber unloads. Throws if the name is + * already provided in this scope or declared as an accessor. + * + * @param name — the service name. + * @param value — the service value. + * @returns a disposer that unregisters the service. + */ +provide(name: K, value: undefined | this[K]): () => void +provide(name: string, value?: any): () => void +``` + +Register a service implementation owned by the current fiber. + +The service becomes visible to dependents in the same isolation scope once the fiber is active; it is unregistered (waking dependents) when the returned disposer runs or the fiber unloads. Throws if the name is already provided in this scope or declared as an accessor. + +- `name` — the service name. +- `value` — the service value. + +**Returns** a disposer that unregisters the service. + +[Source](../../../vendor/cordis/src/reflect.ts#L43) + +### ctx.accessor(name, options) + +```ts cordis-catalog +/** + * Define a computed context property backed by get/set hooks. + * + * The accessor is removed when the current fiber unloads. Throws if the + * name is already declared. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + */ +accessor(name: string, options: Omit): void +``` + +Define a computed context property backed by get/set hooks. + +The accessor is removed when the current fiber unloads. Throws if the name is already declared. + +- `name` — the context property name. +- `options` — the `get` hook and optional `set` hook. + +[Source](../../../vendor/cordis/src/reflect.ts#L55) + +### ctx.mixin(name, mixins) + +```ts cordis-catalog +/** + * Expose selected members of a service directly on `ctx`. + * + * Each mixed-in key becomes an accessor that forwards to the service + * (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. + * Mixins are removed when the current fiber unloads. + * + * @param name — the context property holding the source service. + * @param mixins — keys to forward, or a source-key → ctx-key map. + */ +mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void +mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): void +``` + +Expose selected members of a service directly on `ctx`. + +Each mixed-in key becomes an accessor that forwards to the service (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. Mixins are removed when the current fiber unloads. + +- `name` — the context property holding the source service. +- `mixins` — keys to forward, or a source-key → ctx-key map. + +[Source](../../../vendor/cordis/src/reflect.ts#L66) diff --git a/docs/cordis-catalog/core/events.md b/docs/cordis-catalog/core/events.md new file mode 100644 index 0000000000..2fb64e78a2 --- /dev/null +++ b/docs/cordis-catalog/core/events.md @@ -0,0 +1,207 @@ + + +# Events + +The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md). + +### ctx.parallel(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, running all listeners concurrently. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + * @returns a promise resolving once every listener has settled. + */ +parallel(name: K, ...args: Parameters): Promise +parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise +``` + +Dispatch an event, running all listeners concurrently. + +- `name` — the event name. +- `args` — arguments passed to every listener. + +**Returns** a promise resolving once every listener has settled. + +[Source](../../../vendor/cordis/src/events.ts#L43) + +### ctx.emit(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event synchronously, ignoring listener return values. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + */ +emit(name: K, ...args: Parameters): void +emit(thisArg: NoInfer>, name: K, ...args: Parameters): void +``` + +Dispatch an event synchronously, ignoring listener return values. + +- `name` — the event name. +- `args` — arguments passed to every listener. + +[Source](../../../vendor/cordis/src/events.ts#L52) + +### ctx.serial(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, awaiting listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ +serial(name: K, ...args: Parameters): Promisify> +serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> +``` + +Dispatch an event, awaiting listeners in order until one bails. + +- `name` — the event name. +- `args` — arguments passed to each listener. + +**Returns** the first bail value (non-null, non-false, non-undefined), if any. + +[Source](../../../vendor/cordis/src/events.ts#L62) + +### ctx.bail(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, calling listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ +bail(name: K, ...args: Parameters): ReturnType +bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` + +Dispatch an event, calling listeners in order until one bails. + +- `name` — the event name. +- `args` — arguments passed to each listener. + +**Returns** the first bail value (non-null, non-false, non-undefined), if any. + +[Source](../../../vendor/cordis/src/events.ts#L72) + +### ctx.waterfall(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event whose last argument is a `next` continuation. + * + * Each listener wraps the rest of the chain: calling `next()` invokes the + * next listener (finally the built-in behavior); not calling it vetoes. + * + * @param name — the event name. + * @param args — listener arguments; the final one is the innermost `next`. + * @returns the outermost listener's return value. + */ +waterfall(name: K, ...args: Parameters): ReturnType +waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` + +Dispatch an event whose last argument is a `next` continuation. + +Each listener wraps the rest of the chain: calling `next()` invokes the next listener (finally the built-in behavior); not calling it vetoes. + +- `name` — the event name. +- `args` — listener arguments; the final one is the innermost `next`. + +**Returns** the outermost listener's return value. + +[Source](../../../vendor/cordis/src/events.ts#L85) + +### ctx.on(name, listener, options?) + +```ts cordis-catalog +/** + * Register an event listener owned by the current fiber. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ +on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Register an event listener owned by the current fiber. + +- `name` — the event name to listen for. +- `listener` — called with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](../../../vendor/cordis/src/events.ts#L96) + +### ctx.once(name, listener, options?) + +```ts cordis-catalog +/** + * Same as `on()`, but the listener disposes itself after its first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ +once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Same as `on()`, but the listener disposes itself after its first call. + +- `name` — the event name to listen for. +- `listener` — called at most once with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](../../../vendor/cordis/src/events.ts#L105) + +## EventOptions + +Options accepted by `ctx.on()` and `ctx.once()`. + +```ts cordis-catalog +/** Options accepted by `ctx.on()` and `ctx.once()`. */ +interface EventOptions { + /** Add the listener before existing listeners for the same event. */ + prepend?: boolean + /** Receive the event regardless of context filter checks. */ + global?: boolean +} +``` + +[Source](../../../vendor/cordis/src/events.ts#L111) + +## DispatchMode + +Event dispatch strategy used by the event service. + +`emit` runs synchronous listeners without awaiting them, `parallel` awaits all listeners together, `serial` awaits them in order until one bails, `bail` stops on the first synchronous bail value, and `waterfall` composes listeners around a final `next` callback. + +```ts cordis-catalog +/** + * Event dispatch strategy used by the event service. + * + * `emit` runs synchronous listeners without awaiting them, `parallel` awaits + * all listeners together, `serial` awaits them in order until one bails, + * `bail` stops on the first synchronous bail value, and `waterfall` composes + * listeners around a final `next` callback. + */ +type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' +``` + +[Source](../../../vendor/cordis/src/events.ts#L31) diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md new file mode 100644 index 0000000000..d865ce01fc --- /dev/null +++ b/docs/cordis-catalog/core/fiber.md @@ -0,0 +1,375 @@ + + +# Fiber + +A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it. + +### ctx.effect(execute, label?) + +```ts cordis-catalog +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ +effect(execute: () => SyncEffect, label?: string): Disposable> +effect(execute: () => Effect, label?: string): AsyncDisposable> +``` + +Register a cleanup-aware effect on this fiber. + +`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape. + +- `execute` — the effect body; see `Effect` for accepted shapes. +- `label` — effect label shown in `getEffects()` diagnostics. + +**Returns** a disposer that tears the effect down and settles once done. + +[Source](../../../vendor/cordis/src/fiber.ts#L419) + +### ctx.fiber + +```ts cordis-catalog +/** The fiber (plugin runtime instance) that owns this context. */ +fiber: Fiber +``` + +The fiber (plugin runtime instance) that owns this context. + +[Source](../../../vendor/cordis/src/fiber.ts#L11) + +## The Fiber class + +Runtime instance of one plugin application. + +A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. + +[Source](../../../vendor/cordis/src/fiber.ts#L183) + +### fiber.uid + +```ts cordis-catalog +/** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ +public uid: number | null +``` + +Unique id within the registry; 0 for the root fiber, `null` once disposed. + +[Source](../../../vendor/cordis/src/fiber.ts#L185) + +### fiber.ctx + +```ts cordis-catalog +/** The context this fiber's plugin runs in (extends the parent context). */ +public readonly ctx: Context +``` + +The context this fiber's plugin runs in (extends the parent context). + +[Source](../../../vendor/cordis/src/fiber.ts#L187) + +### fiber.config + +```ts cordis-catalog +/** The validated plugin config (updated by `update()`). */ +public config: any +``` + +The validated plugin config (updated by `update()`). + +[Source](../../../vendor/cordis/src/fiber.ts#L189) + +### fiber.state + +```ts cordis-catalog +/** Current lifecycle state; transitions emit `internal/status`. */ +public state +``` + +Current lifecycle state; transitions emit `internal/status`. + +[Source](../../../vendor/cordis/src/fiber.ts#L191) + +### fiber.dispose + +```ts cordis-catalog +/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ +public readonly dispose: () => Promise +``` + +Dispose this fiber: unload the plugin, then settle once cleanup finished. + +[Source](../../../vendor/cordis/src/fiber.ts#L193) + +### fiber.store + +```ts cordis-catalog +/** Snapshot of required service implementations while loaded; `undefined` otherwise. */ +public store: Dict | undefined +``` + +Snapshot of required service implementations while loaded; `undefined` otherwise. + +[Source](../../../vendor/cordis/src/fiber.ts#L195) + +### fiber.inertia + +```ts cordis-catalog +/** The in-flight load/unload transition, if one is currently running. */ +public inertia: Promise | undefined +``` + +The in-flight load/unload transition, if one is currently running. + +[Source](../../../vendor/cordis/src/fiber.ts#L197) + +### fiber.name + +```ts cordis-catalog +/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ +get name() +``` + +The plugin's display name, inherited from the nearest named ancestor, else `'root'`. + +[Source](../../../vendor/cordis/src/fiber.ts#L340) + +### fiber.assertActive() + +```ts cordis-catalog +/** + * Throw if the fiber has already been disposed. + * + * @returns nothing when the fiber is still active. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. + */ +assertActive() +``` + +Throw if the fiber has already been disposed. + +**Returns** nothing when the fiber is still active. + +[Source](../../../vendor/cordis/src/fiber.ts#L355) + +### fiber.effect(execute, label?) + +```ts cordis-catalog +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ +effect(execute: () => SyncEffect, label?: string): Disposable> +effect(execute: () => Effect, label?: string): AsyncDisposable> +``` + +Register a cleanup-aware effect on this fiber. + +`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape. + +- `execute` — the effect body; see `Effect` for accepted shapes. +- `label` — effect label shown in `getEffects()` diagnostics. + +**Returns** a disposer that tears the effect down and settles once done. + +[Source](../../../vendor/cordis/src/fiber.ts#L419) + +### fiber.getEffects() + +```ts cordis-catalog +/** + * Return metadata for currently registered effects. + * + * @returns one {@link EffectMeta} tree per labeled live effect. + */ +getEffects() +``` + +Return metadata for currently registered effects. + +**Returns** one `EffectMeta` tree per labeled live effect. + +[Source](../../../vendor/cordis/src/fiber.ts#L572) + +### fiber.await() + +```ts cordis-catalog +/** + * Wait for current lifecycle work and rethrow startup errors. + * + * @returns this fiber, once it has settled into a stable state. + * @throws the config-validation or plugin-startup error, if any. + */ +async await() +``` + +Wait for current lifecycle work and rethrow startup errors. + +**Returns** this fiber, once it has settled into a stable state. + +[Source](../../../vendor/cordis/src/fiber.ts#L701) + +### fiber.restart() + +```ts cordis-catalog +/** + * Dispose and immediately reload this plugin with its current config. + * + * @returns a promise resolving once the reload settled. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. + */ +async restart() +``` + +Dispose and immediately reload this plugin with its current config. + +**Returns** a promise resolving once the reload settled. + +[Source](../../../vendor/cordis/src/fiber.ts#L715) + +### fiber.update(config, noSave?) + +```ts cordis-catalog +/** + * Validate and apply new config, then restart the plugin. + * + * Runs the `internal/update` waterfall first, so update hooks (and HMR) + * can veto or replace the restart. + * + * @param config — the new raw config; validated before anything restarts. + * @param noSave — hint for persistence hooks not to write the change back. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. + */ +update(config: any, noSave = false) +``` + +Validate and apply new config, then restart the plugin. + +Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart. + +- `config` — the new raw config; validated before anything restarts. +- `noSave` — hint for persistence hooks not to write the change back. + +**Returns** nothing; the restart runs behind the `internal/update` waterfall. + +[Source](../../../vendor/cordis/src/fiber.ts#L733) + +## Effect + +Effect body result accepted by `ctx.effect()` and plugin startup. + +Either a single disposer, a promise of one, or a (possibly async) iterable yielding several — generator effects register each yielded disposer as it is produced. + +```ts cordis-catalog +/** + * Effect body result accepted by `ctx.effect()` and plugin startup. + * + * Either a single disposer, a promise of one, or a (possibly async) iterable + * yielding several — generator effects register each yielded disposer as it + * is produced. + */ +type Effect = + | SyncEffect + | AsyncEffect +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L82) + +## Disposable + +Function returned by an effect to release resources during disposal. + +Disposers run in reverse registration order when the owning fiber unloads; they may be async, in which case unloading awaits them. + +```ts cordis-catalog +/** + * Function returned by an effect to release resources during disposal. + * + * Disposers run in reverse registration order when the owning fiber unloads; + * they may be async, in which case unloading awaits them. + */ +type Disposable = () => T +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L73) + +## EffectMeta + +Tree node used to expose nested effect labels for diagnostics. + +```ts cordis-catalog +/** Tree node used to expose nested effect labels for diagnostics. */ +interface EffectMeta { + /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ + label: string + /** Metadata of nested effects registered while this effect ran. */ + children: EffectMeta[] +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L95) + +## CordisError + +Framework error with a stable machine-readable code. + +```ts cordis-catalog +/** Framework error with a stable machine-readable code. */ +class CordisError extends Error { + /** + * @param code — the stable error code; also the default message. + * @param message — optional human-readable override. + */ + constructor(public code: CordisError.Code, message?: string) +} + +/** Cordis error code definitions. */ +namespace CordisError { + export type Code = keyof typeof Code + + export const Code = { + INACTIVE_EFFECT: 'cannot create effect on inactive context', + } as const +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L156) + +## ValidationError + +Error raised when plugin configuration fails standard-schema validation. + +```ts cordis-catalog +/** Error raised when plugin configuration fails standard-schema validation. */ +class ValidationError extends TypeError { + name = 'ValidationError' + + /** + * Build the aggregated message from schema issues. + * + * @param issues — the standard-schema issues, one message line each. + */ + constructor(issues: readonly StandardSchemaV1.Issue[]) +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L18) diff --git a/docs/cordis-catalog/core/registry.md b/docs/cordis-catalog/core/registry.md new file mode 100644 index 0000000000..2772dca723 --- /dev/null +++ b/docs/cordis-catalog/core/registry.md @@ -0,0 +1,152 @@ + + +# Registry + +Plugin loading and dependency injection. + +### ctx.inject(deps, callback) + +```ts cordis-catalog +/** + * Run a callback once the requested services are available. + * + * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback + * is unloaded and re-run whenever a required service changes. + * + * @param deps — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ +inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike +``` + +Run a callback once the requested services are available. + +Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloaded and re-run whenever a required service changes. + +- `deps` — required services, as an array or a name → config map. +- `callback` — plugin body called with `(ctx, config)`. + +**Returns** the fiber; awaiting it settles once loading finished. + +[Source](../../../vendor/cordis/src/registry.ts#L175) + +### ctx.plugin(plugin, ...args) + +```ts cordis-catalog +/** + * Load a plugin in the current context. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param args — the plugin config, validated against its `Config` schema. + * @returns the fiber; awaiting it settles once loading finished + * (rejecting on config or startup errors). + */ +plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike +``` + +Load a plugin in the current context. + +- `plugin` — a function, class, or `{ apply }` object plugin. +- `args` — the plugin config, validated against its `Config` schema. + +**Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). + +[Source](../../../vendor/cordis/src/registry.ts#L184) + +## Plugin + +Supported plugin entrypoint shapes. + +```ts cordis-catalog +/** Supported plugin entrypoint shapes. */ +type Plugin = + | Plugin.Function + | Plugin.Constructor + | Plugin.Object + +/** Types associated with plugin entrypoints and runtime records. */ +namespace Plugin { + /** Shared metadata understood by the plugin registry and related tooling. */ + export interface Base { + /** Display name used for fiber diagnostics and logger names. */ + name?: string + /** Standard-schema validator applied to config before the plugin starts. */ + Config?: StandardSchemaV1 + /** Services the plugin requires; it only loads while all are available. */ + inject?: Inject + /** Service name(s) the plugin provides (read by `Service` and by loaders). */ + provide?: string | string[] + /** Service names whose intercept config the plugin declares it consumes. */ + intercept?: Dict + } + + export interface Transform { + /** Marks the transform object as a schema/config transform. */ + schema?: true + /** Convert user-facing config to runtime config. */ + Config: (config: S) => T + } + + /** Function plugin called with `(ctx, config)`. */ + export interface Function extends Base { + (ctx: Context, config: T): any + } + + /** Class plugin constructed with `(ctx, config)`. */ + export interface Constructor extends Base { + new (ctx: Context, config: T): any + } + + /** Object plugin with an `apply(ctx, config)` method. */ + export interface Object extends Base { + apply(ctx: Context, config: T): any + } + + /** Mutable registry record shared by all fibers of one plugin callback. */ + export interface Runtime { + /** Display name copied from the first registered plugin shape. */ + name?: string + /** Every live fiber of this plugin (one per `ctx.plugin()` call). */ + fibers: DisposableList + /** The executable entrypoint all fibers share (registry identity key). */ + callback: globalThis.Function + /** Standard-schema validator applied to each fiber's config. */ + Config?: StandardSchemaV1 + } +} +``` + +[Source](../../../vendor/cordis/src/registry.ts#L91) + +## Inject + +Service dependency declaration accepted by plugins and the `@Inject` decorator. + +Array form requests services without intercept config. Object form maps each service name to optional intercept config for the plugin context. + +```ts cordis-catalog +/** + * Service dependency declaration accepted by plugins and the `@Inject` + * decorator. + * + * Array form requests services without intercept config. Object form maps each + * service name to optional intercept config for the plugin context. + */ +type Inject = (keyof M)[] | { [K in keyof M]?: M[K] } + +/** Utilities for normalizing plugin dependency declarations. */ +namespace Inject { + /** + * Convert array/object/class-inherited inject metadata into a plain map. + * + * @param inject — the declaration to normalize; `null`/`undefined` add nothing. + * @param result — the map to fill (service name → intercept config or `null`). + * @returns `result`. + */ + export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) +} +``` + +[Source](../../../vendor/cordis/src/registry.ts#L18) diff --git a/docs/cordis-catalog/core/service.md b/docs/cordis-catalog/core/service.md new file mode 100644 index 0000000000..84b74f98df --- /dev/null +++ b/docs/cordis-catalog/core/service.md @@ -0,0 +1,102 @@ + + +# Service + +The base class for context services. A subclass loaded as a plugin registers itself as `ctx.`. + +Base class for services that expose a named API on `ctx`. + +Subclasses call `super(ctx, name)` from their constructor. The service is registered immediately and is automatically removed with the owning fiber. + +[Source](../../../vendor/cordis/src/service.ts#L11) + +### service.name + +```ts cordis-catalog +/** The service name this instance is registered under. */ +public name!: string +``` + +The service name this instance is registered under. + +[Source](../../../vendor/cordis/src/service.ts#L30) + +## Static members + +### Service.init + +```ts cordis-catalog +/** Symbol key of an instance method run after construction (class plugins). */ +static readonly init: unique symbol +``` + +Symbol key of an instance method run after construction (class plugins). + +[Source](../../../vendor/cordis/src/service.ts#L13) + +### Service.check + +```ts cordis-catalog +/** Symbol key of the availability predicate passed to `ctx.provide()`. */ +static readonly check: unique symbol +``` + +Symbol key of the availability predicate passed to `ctx.provide()`. + +[Source](../../../vendor/cordis/src/service.ts#L15) + +### Service.config + +```ts cordis-catalog +/** Symbol key of the phantom intercept-config type parameter. */ +static readonly config: unique symbol +``` + +Symbol key of the phantom intercept-config type parameter. + +[Source](../../../vendor/cordis/src/service.ts#L17) + +### Service.invoke + +```ts cordis-catalog +/** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */ +static readonly invoke: unique symbol +``` + +Symbol key of the call body making a service callable (e.g. `ctx.logger()`). + +[Source](../../../vendor/cordis/src/service.ts#L19) + +### Service.extend + +```ts cordis-catalog +/** Symbol key of the helper deriving an extended service instance. */ +static readonly extend: unique symbol +``` + +Symbol key of the helper deriving an extended service instance. + +[Source](../../../vendor/cordis/src/service.ts#L21) + +### Service.tracker + +```ts cordis-catalog +/** Symbol key of the tracker metadata used for context tracing. */ +static readonly tracker: unique symbol +``` + +Symbol key of the tracker metadata used for context tracing. + +[Source](../../../vendor/cordis/src/service.ts#L23) + +### Service.resolveConfig + +```ts cordis-catalog +/** Symbol key of the intercept-config resolution helper below. */ +static readonly resolveConfig: unique symbol +``` + +Symbol key of the intercept-config resolution helper below. + +[Source](../../../vendor/cordis/src/service.ts#L25) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..e4a1314244 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -7,7 +7,7 @@ Every cordis event a plugin can listen to: exact signature, dispatch mode, and o This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. -The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md). Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..26d6130dc4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -7,7 +7,7 @@ Every `ctx.` service a plugin can call: the exact public interface with ori This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. -The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md). ## `ctx.agentLoop` — `AgentLoop` diff --git a/scripts/cordis-core-api.spec.ts b/scripts/cordis-core-api.spec.ts new file mode 100644 index 0000000000..d35899553c --- /dev/null +++ b/scripts/cordis-core-api.spec.ts @@ -0,0 +1,49 @@ +/** Tests for the generated Cordis core API reference. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + CORDIS_CORE_API_PAGES, + renderCordisCoreApiPage, + renderCordisCoreApiPages, + type CordisCoreApiPage, +} from './cordis-core-api.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('Cordis core API generation', () => { + it('renders the five detailed pages from pinned vendor declarations', () => { + const pages = renderCordisCoreApiPages() + expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out)) + expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)') + expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode') + expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta') + expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin') + expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig') + + const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? '' + expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.') + expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.') + expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.') + }) + + it('rejects a public core class without source JSDoc', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-core-api-')) + roots.push(root) + mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true }) + writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n') + const page: CordisCoreApiPage = { + out: 'docs/cordis-catalog/core/service.md', + title: 'Service', + intro: 'Service API.', + sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }], + } + expect(() => renderCordisCoreApiPage(page, root)).toThrow('class Service') + }) +}) diff --git a/scripts/cordis-core-api.ts b/scripts/cordis-core-api.ts new file mode 100644 index 0000000000..a2400fdb54 --- /dev/null +++ b/scripts/cordis-core-api.ts @@ -0,0 +1,433 @@ +/** Generate detailed Cordis core API pages from pinned vendor declarations. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' +import { cordisModuleBody } from './cordis-walk.ts' + +const root = resolve(import.meta.dirname, '..') +const FENCE = 'ts cordis-catalog' + +/** One declaration group rendered on a Cordis core API page. */ +type CordisCoreApiSection = + | { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string } + | { kind: 'context-merge'; file: string; heading?: string } + | { kind: 'decl'; file: string; symbol: string } + +/** One generated Cordis core API page. */ +export interface CordisCoreApiPage { + out: string + title: string + intro: string + sections: CordisCoreApiSection[] +} + +/** Explicit editorial grouping for the pinned Cordis core surface. */ +export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [ + { + out: 'docs/cordis-catalog/core/context.md', + title: 'Context', + intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' }, + { kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' }, + ], + }, + { + out: 'docs/cordis-catalog/core/events.md', + title: 'Events', + intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/events.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' }, + ], + }, + { + out: 'docs/cordis-catalog/core/fiber.md', + title: 'Fiber', + intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' }, + { kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' }, + ], + }, + { + out: 'docs/cordis-catalog/core/registry.md', + title: 'Registry', + intro: 'Plugin loading and dependency injection.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' }, + ], + }, + { + out: 'docs/cordis-catalog/core/service.md', + title: 'Service', + intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.`.', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }, + ], + }, +] + +interface MemberDoc { + name: string + heading: string + signatures: string[] + jsDoc: string + doc: string + params: { name: string; text: string }[] + returns: string | null + source: string +} + +interface RenderContext { + scanRoot: string + cache: Map + violations: string[] +} + +function load(ctx: RenderContext, rel: string): { sf: ts.SourceFile; text: string } { + const cached = ctx.cache.get(rel) + if (cached !== undefined) return cached + const text = readFileSync(resolve(ctx.scanRoot, rel), 'utf8') + const entry = { sf: ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true), text } + ctx.cache.set(rel, entry) + return entry +} + +function sourceJsDoc(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + if (raw === '') return '' + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, node.getStart(sf)) + return raw.split('\n') + .map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent) + ? sourceLine.slice(indent.length) + : sourceLine) + .join('\n') +} + +function signatureOf(member: ts.Node, sf: ts.SourceFile): string { + const full = member.getText(sf) + const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body + ?? (member as { initializer?: ts.Node }).initializer + const signature = tail + ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') + : full + return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() +} + +function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string { + const names = parameters + .filter(parameter => !(ts.isIdentifier(parameter.name) && parameter.name.text === 'this')) + .map((parameter) => { + const rest = parameter.dotDotDotToken ? '...' : '' + const optional = parameter.questionToken || parameter.initializer ? '?' : '' + return `${rest}${parameter.name.getText(sf)}${optional}` + }) + return `(${names.join(', ')})` +} + +function isPublicInstance(member: ts.ClassElement): boolean { + const modifiers = ts.getCombinedModifierFlags(member) + if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false + if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +function isPublicStatic(member: ts.ClassElement): boolean { + const modifiers = ts.getCombinedModifierFlags(member) + if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false + if (!(modifiers & ts.ModifierFlags.Static)) return false + if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +type Member = ts.MethodDeclaration + | ts.MethodSignature + | ts.PropertyDeclaration + | ts.PropertySignature + | ts.GetAccessorDeclaration + +function memberDoc(ctx: RenderContext, where: string, name: string, group: Member[], rel: string): MemberDoc { + const { sf, text } = load(ctx, rel) + const first = group[0] + if (first === undefined) throw new Error(`cordis-core-api: empty member group for ${name}.`) + const rawDocs = group.map(member => sourceJsDoc(text, sf, member)) + const docIndex = rawDocs.findIndex(raw => parseJsDoc(raw).doc !== '') + const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '') + const doc = parseJsDoc(raw).doc + if (doc === '') ctx.violations.push(`${where} has no JSDoc prose.`) + const { params: tags, returns } = parseTags(raw) + const functionMembers = group.filter((member): member is ts.MethodDeclaration | ts.MethodSignature => + ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) + const docCarrier = functionMembers[docIndex === -1 ? 0 : docIndex] + const params: { name: string; text: string }[] = [] + if (docCarrier !== undefined) { + checkParams(where, 'cordis-core-api', docCarrier.parameters, tags, sf, + parameter => ts.isIdentifier(parameter.name) && parameter.name.text === 'this', ctx.violations) + if (docCarrier.type !== undefined) { + checkReturns(where, docCarrier.type, returns, sf, ctx.violations) + } else if (returns === null && ts.isMethodDeclaration(docCarrier)) { + ctx.violations.push(`${where} has no return type annotation; document the result with @returns.`) + } + for (const parameter of docCarrier.parameters) { + if (!ts.isIdentifier(parameter.name) || parameter.name.text === 'this') continue + const text = tags.get(parameter.name.text) + if (text !== undefined) params.push({ name: parameter.name.text, text }) + } + } + const headingSource = docCarrier ?? functionMembers[0] + const signatures = ts.isMethodDeclaration(first) && functionMembers.length > 1 + ? functionMembers.filter(member => ts.isMethodDeclaration(member) && member.body === undefined) + : group + return { + name, + heading: headingSource === undefined ? '' : headingParams(headingSource.parameters, sf), + signatures: signatures.map(member => signatureOf(member, sf)), + jsDoc: raw, + doc, + params, + returns, + source: pointer(rel, sf, first), + } +} + +function heritageMembers( + statement: ts.InterfaceDeclaration, + sf: ts.SourceFile, + groups: Map, +): void { + for (const clause of statement.heritageClauses ?? []) { + for (const type of clause.types) { + if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue + const [target, keys] = type.typeArguments ?? [] + if (target === undefined || keys === undefined || !ts.isTypeReferenceNode(target)) continue + const targetName = target.typeName.getText(sf) + const cls = sf.statements.find( + (entry): entry is ts.ClassDeclaration => ts.isClassDeclaration(entry) && entry.name?.text === targetName, + ) + if (cls === undefined) continue + const picked = new Set() + const collect = (node: ts.TypeNode): void => { + if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text) + if (ts.isUnionTypeNode(node)) node.types.forEach(collect) + } + collect(keys) + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue + const name = member.name.getText(sf) + if (!picked.has(name)) continue + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + } + } +} + +function contextMergeMembers(ctx: RenderContext, rel: string): MemberDoc[] { + const { sf } = load(ctx, rel) + const body = cordisModuleBody(sf) + if (body === null) throw new Error(`cordis-core-api: ${rel} has no Context module merge.`) + const groups = new Map() + for (const statement of body.statements) { + if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'Context') continue + heritageMembers(statement, sf, groups) + for (const member of statement.members) { + if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue + if (ts.isComputedPropertyName(member.name)) continue + const name = member.name.getText(sf) + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + } + return [...groups.entries()].map(([name, group]) => + memberDoc(ctx, `ctx.${name} (${rel})`, name, group, rel)) +} + +function classMembers(ctx: RenderContext, rel: string, className: string): { + doc: string + instance: MemberDoc[] + statics: MemberDoc[] + source: string +} { + const { sf, text } = load(ctx, rel) + const cls = sf.statements.find( + (statement): statement is ts.ClassDeclaration => + ts.isClassDeclaration(statement) && statement.name?.text === className, + ) + if (cls === undefined) throw new Error(`cordis-core-api: class ${className} not found in ${rel}.`) + const doc = parseJsDoc(rawJsDoc(text, cls)).doc + if (doc === '') ctx.violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`) + const instance = new Map() + const statics = new Map() + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member) && !ts.isGetAccessorDeclaration(member)) continue + const name = member.name.getText(sf) + if (isPublicInstance(member)) { + const group = instance.get(name) ?? [] + group.push(member) + instance.set(name, group) + } else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) { + const group = statics.get(name) ?? [] + group.push(member) + statics.set(name, group) + } + } + const declaration = sf.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && statement.name.text === className, + ) + for (const member of declaration?.members ?? []) { + if (!ts.isPropertySignature(member) || ts.isComputedPropertyName(member.name)) continue + const name = member.name.getText(sf) + const group = instance.get(name) ?? [] + group.push(member) + instance.set(name, group) + } + const render = (groups: Map, prefix: string): MemberDoc[] => + [...groups.entries()].map(([name, group]) => memberDoc(ctx, `${prefix}${name} (${rel})`, name, group, rel)) + return { + doc, + instance: render(instance, `${className}#`), + statics: render(statics, `${className}.`), + source: pointer(rel, sf, cls), + } +} + +function stripBodies(node: ts.Node, sf: ts.SourceFile): string { + const cuts: { start: number; end: number }[] = [] + const visit = (entry: ts.Node): void => { + const functionLike = ts.isMethodDeclaration(entry) + || ts.isConstructorDeclaration(entry) + || ts.isFunctionDeclaration(entry) + || ts.isGetAccessorDeclaration(entry) + || ts.isSetAccessorDeclaration(entry) + if (functionLike && entry.body !== undefined) { + const signatureEnd = (entry.type ?? entry.parameters.at(-1) ?? entry).getEnd() + cuts.push({ start: signatureEnd, end: entry.body.getEnd() }) + return + } + entry.forEachChild(visit) + } + visit(node) + const base = node.getStart(sf) + let output = node.getText(sf) + for (const cut of cuts.sort((left, right) => right.start - left.start)) { + const head = output.slice(0, cut.start - base) + const between = output.slice(cut.start - base, cut.end - base) + const bodyBrace = between.indexOf('{') + output = head + between.slice(0, bodyBrace).trimEnd() + output.slice(cut.end - base) + } + return output +} + +function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { doc: string; code: string; source: string } { + const { sf, text } = load(ctx, rel) + const matches = sf.statements.filter((statement) => { + const named = ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) + || ts.isClassDeclaration(statement) + || ts.isEnumDeclaration(statement) + || ts.isModuleDeclaration(statement) + return named && statement.name?.getText(sf) === symbol + }) + const first = matches[0] + if (first === undefined) throw new Error(`cordis-core-api: declaration ${symbol} not found in ${rel}.`) + const doc = parseJsDoc(sourceJsDoc(text, sf, first)).doc + const code = matches.map((statement) => { + const jsDoc = sourceJsDoc(text, sf, statement) + const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '') + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + }).join('\n\n') + return { doc, code, source: pointer(rel, sf, first) } +} + +function sourceLink(source: string): string { + const [file, line] = source.split(':') + return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})` +} + +function unlink(text: string): string { + return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_match, target: string, label?: string) => { + const name = label?.trim() + return name && name !== '' ? name : `\`${target}\`` + }) +} + +function prose(doc: string): string[] { + const paragraphs = unlink(doc) + .split(/\n\s*\n/) + .map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim()) + .filter(paragraph => paragraph !== '') + return paragraphs.flatMap((paragraph, index) => index === 0 ? [paragraph] : ['', paragraph]) +} + +function renderMember(prefix: string, member: MemberDoc): string[] { + const lines = [`### ${prefix}${member.name}${member.heading}`, '', `\`\`\`${FENCE}`] + if (member.jsDoc !== '') lines.push(member.jsDoc) + lines.push(...member.signatures, '```', '') + if (member.doc !== '') lines.push(...prose(member.doc), '') + for (const parameter of member.params) lines.push(`- \`${parameter.name}\` — ${unlink(parameter.text)}`) + if (member.params.length > 0) lines.push('') + if (member.returns !== null && member.returns !== '') lines.push(`**Returns** ${unlink(member.returns)}`, '') + lines.push(sourceLink(member.source), '') + return lines +} + +/** Render one detailed Cordis core API page and reject undocumented members. */ +export function renderCordisCoreApiPage( + page: CordisCoreApiPage, + scanRoot: string = root, +): string { + const ctx: RenderContext = { scanRoot, cache: new Map(), violations: [] } + const lines = [ + '', + '', + `# ${page.title}`, + '', + page.intro, + '', + ] + for (const section of page.sections) { + if (section.kind !== 'decl' && section.heading !== undefined) lines.push(`## ${section.heading}`, '') + if (section.kind === 'context-merge') { + for (const member of contextMergeMembers(ctx, section.file)) lines.push(...renderMember('ctx.', member)) + } else if (section.kind === 'class') { + const cls = classMembers(ctx, section.file, section.symbol) + if (cls.doc !== '') lines.push(...prose(cls.doc), '') + lines.push(sourceLink(cls.source), '') + const prefix = section.prefix ?? `${section.symbol.toLowerCase()}.` + for (const member of cls.instance) lines.push(...renderMember(prefix, member)) + if (cls.statics.length > 0) { + lines.push('## Static members', '') + for (const member of cls.statics) lines.push(...renderMember(`${section.symbol}.`, member)) + } + } else { + const declaration = declarationPaste(ctx, section.file, section.symbol) + lines.push(`## ${section.symbol}`, '') + if (declaration.doc !== '') lines.push(...prose(declaration.doc), '') + lines.push(`\`\`\`${FENCE}`, declaration.code, '```', '', sourceLink(declaration.source), '') + } + } + reportViolations('gen-cordis-catalog', ctx.violations) + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n` +} + +/** Render every detailed Cordis core API page. */ +export function renderCordisCoreApiPages(scanRoot: string = root): Map { + return new Map(CORDIS_CORE_API_PAGES.map(page => [page.out, renderCordisCoreApiPage(page, scanRoot)])) +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9ed088aa1d..fd6fa051f5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -5,9 +5,10 @@ * curated table below. `--check` verifies both committed artifacts. */ -import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve, sep } from 'node:path' +import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve, sep } from 'node:path' import ts from 'typescript' +import { renderCordisCoreApiPages } from './cordis-core-api.ts' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts' @@ -503,7 +504,7 @@ export function renderEvents(events: EventEntry[]): string { '', GATE_NOTICE, '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).', '', 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', '', @@ -538,7 +539,7 @@ export function renderServices(services: ServiceEntry[]): string { '', GATE_NOTICE, '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).', '', ] for (const s of services) lines.push(...renderService(s)) @@ -562,6 +563,7 @@ function main(): void { const outputs: [string, string][] = [ [OUT_EVENTS, renderEvents(collectEvents())], [OUT_SERVICES, renderServices(collectServices())], + ...renderCordisCoreApiPages(), ] if (process.argv.includes('--check')) { const stale: string[] = [] @@ -578,15 +580,19 @@ function main(): void { if (committed !== content) stale.push(out) } if (stale.length === 0) { - console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`) + console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`) process.exit(0) } console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`) process.exit(1) } - for (const [out, content] of outputs) writeFileSync(resolve(root, out), content) - console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`) + for (const [out, content] of outputs) { + const destination = resolve(root, out) + mkdirSync(dirname(destination), { recursive: true }) + writeFileSync(destination, content) + } + console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`) } // Run only when invoked as a script, not when imported by a test. diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 7378152ac5..bd6cfb14c7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -160,6 +160,18 @@ describe('docsPages locale routes', () => { } } }) + + it('publishes the Cordis core API under matching locale structures', () => { + const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md'] + for (const file of files) { + const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`) + const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`) + expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`) + expect(root?.section).toBe('Cordis API') + expect(english?.source).toBe(root?.source) + expect(english?.section).toBe('Cordis Core API') + } + }) }) describe('addProjectionFrontmatter', () => { diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b553d6b2da..b9f38b0f7e 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -15,6 +15,7 @@ const sectionOrder = [ '实战', '概念', '生成参考', + 'Cordis API', '数据结构', '开发手册', 'Guide', @@ -23,6 +24,7 @@ const sectionOrder = [ 'Practice', 'Concepts', 'Generated reference', + 'Cordis Core API', 'Data structures', 'Cookbook', ] diff --git a/website/docs.ts b/website/docs.ts index 0e6897afe3..528fa36110 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -237,6 +237,21 @@ const reference = mirroredPages([ section: { root: '生成参考', en: 'Generated reference' }, order, })), + ...([ + ['context.md', 'Context', 'Context'], + ['events.md', 'Events', 'Events'], + ['fiber.md', 'Fiber', 'Fiber'], + ['registry.md', 'Plugin Registry', 'Plugin Registry'], + ['service.md', 'Service', 'Service'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/cordis-catalog/core/${file}`, + route: `reference/cordis-api/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: 'Cordis API', en: 'Cordis Core API' }, + order, + })), ...([ ['core.md', '核心数据结构', 'Core data structures'], ['scope.md', '作用域', 'Scopes'], From 1468773dcd7f2a6eb0ffa85978a7ad959a152995 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:39:40 +0800 Subject: [PATCH 081/132] fix(goal): simplify blockers into one durable phase --- ...rsisted-same-session-goal-domain.i18n.yaml | 4 +- ...7-19-persisted-same-session-goal-domain.md | 12 +- ...9-persisted-same-session-goal-domain.zh.md | 12 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 32 +----- docs/core-data-structures/goal.md | 26 +++-- docs/event-producer-consumer.md | 2 +- docs/glossary.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 28 ++--- packages/goal/goal/README.md | 8 +- packages/goal/goal/src/fold.ts | 61 +++++----- packages/goal/goal/src/index.ts | 96 ++++++++-------- packages/goal/goal/src/types.ts | 21 ++-- packages/goal/goal/tests/goal.spec.ts | 104 +++++++++++------- scripts/gen-cordis-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 2 +- website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/goals.md | 89 +++------------ 18 files changed, 219 insertions(+), 286 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index d398150b73..b960f69e71 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: 1484a919851c18ce978c93c068f6096bbf3b733f -2026-07-19-persisted-same-session-goal-domain.zh.md: 8bcedc6cab6b9219a33a655e8fcbc53762b7413f +2026-07-19-persisted-same-session-goal-domain.md: 75355aa8d94789e0cc227d393c50139708a73f6a +2026-07-19-persisted-same-session-goal-domain.zh.md: fd7e31f4b6a5acec1d2b44fb3088e301b36abd5c diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md index 1484a91985..75355aa8d9 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md @@ -12,13 +12,13 @@ Durable lifecycle and permission to continue are different facts. A session may ## Decision -`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `resolveCreate()` materializes it before mutation. +`@deepseek-ai/dsh-goal` in `packages/goal/goal/` owns one current same-session goal through `ctx.goals`. A goal has a branded id, objective, durable phase, compare-and-set revision, and `maxGoalRounds`. `defaultMaxGoalRounds` is a validated deployment setting with default `256`; `create()` materializes it internally before mutation rather than exposing resolution as another service verb. -The durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, limit transitions, and clear disarm it. Edits preserve activation. Activation is never part of the persisted snapshot. +The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked snapshot includes a policy-owned lower-kebab-case code and a normalized free-form message, so usage limits, round caps, execution failures, and human-input dependencies share one lifecycle state without losing their cause. A separate live activation is `armed` or `disarmed`. Creation and explicit resume arm activation; pause, completion, blocking, and clear disarm it. Edits preserve activation and any blocker reason; resume and completion clear that reason. Activation is never part of the persisted snapshot. ### Durable record and replay -Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. +Every non-clear mutation uses `Agent.inject()` to append a raw, model-visible `context/message` containing a versioned full snapshot. Clear appends a revisioned tombstone. The context source is `{ kind: 'goal', goalId, revision, round: 0 }`; metadata and rendered `...` content must agree exactly. This descriptive delimiter follows the repository's existing `` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. The replay fold validates JSON shape, source attribution, rendered content, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds are positive sequential `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired. @@ -26,7 +26,7 @@ When `Agent.inject()` defers a mutation inside an active tool batch, the service ### Lifecycle and live activation -At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a stopped phase or a disarmed active goal only when the round cap has remaining capacity; budget limiting requires the admitted count to have reached the cap. +At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a paused or blocked phase, or a disarmed active goal, only when the round cap has remaining capacity. The domain validates blocker reason shape but deliberately leaves reason codes and the decision to block to policy consumers. A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. Resume and fork therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy surface may explicitly call resume and arm the goal. @@ -36,7 +36,7 @@ The service accepts only the exact live `Agent` object registered under its id. ## Testing -Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, cap enforcement, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. +Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start disarming and active-goal rearming, FIFO deferred mutation reconciliation, reentrant append observation, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, source/content agreement, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the model-visible snapshot and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate. ## Alternatives considered @@ -52,7 +52,7 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r - Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation. - Full snapshots simplify inspection and strict replay but repeat the objective and state fields in model history until compaction shadows them. - Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early. -- Round caps bound continuation count only; token, currency, time, and provider limits remain separate policy concerns. +- Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work. ## Known limitations and deferred work diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md index 8bcedc6cab..fd7e31f4b6 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`resolveCreate()` 在变更前将其解析为完整值。 +位于 `packages/goal/goal/` 的 `@deepseek-ai/dsh-goal` 通过 `ctx.goals` 管理一个当前的同会话目标。目标包含品牌化 id、目标描述、持久阶段、比较并交换修订号和 `maxGoalRounds`。`defaultMaxGoalRounds` 是经过校验的部署配置,默认值为 `256`;`create()` 在变更前于内部将其解析为完整值,而不会把解析过程暴露为额外的服务动词。 -持久阶段包括 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 和 `complete`。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞、达到限制和清除都会解除激活。编辑保留激活态。持久快照绝不包含激活态。 +持久阶段包括 `active`、`paused`、`blocked` 和 `complete`。阻塞快照包含由策略提供的 kebab-case 小写代码和规范化自由文本消息,因此用量限制、回合上限、执行失败和等待人工输入可以共享一个生命周期状态而不丢失原因。独立的实时激活态为 `armed` 或 `disarmed`。创建与显式恢复会激活目标;暂停、完成、阻塞和清除都会解除激活。编辑保留激活态及阻塞原因;恢复和完成会清除该原因。持久快照绝不包含激活态。 ### 持久记录与回放 -每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 +每次非清除变更都通过 `Agent.inject()` 追加一条原始且模型可见的 `context/message`,其中包含带版本的完整快照。清除操作追加带修订号的墓碑。上下文来源为 `{ kind: 'goal', goalId, revision, round: 0 }`;元数据必须与渲染后的 `...` 内容完全一致。这个描述性分隔符沿用了仓库已有的 `` 约定,也符合 [Anthropic 关于用一致且描述明确的 XML 标签组织混合提示词内容的公开指南](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags)。这是公开的模型体验先例,并非对任何提供方专有训练语料的推断。会话日志是唯一的持久事实来源,因此持久化和 fork 会继承目标记录,而无需另设数据库或头字段。 回放折叠会校验 JSON 形状、来源归属、渲染内容、新 id、修订连续性、生命周期转换、计数器以及单个目标内单调递增的时间戳。目标回合是当前活跃修订上带正数且连续编号的 `user/message` 来源,且不能超过 `maxGoalRounds`;普通会话轮次不会影响该计数器。当前格式的畸形记录会使回放失败,而不会被忽略或修复。 @@ -26,7 +26,7 @@ Status: implemented ### 生命周期与实时激活态 -最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,停止阶段或已解除激活的活跃目标才能恢复;只有已接纳回合数达到上限后,才能标记预算受限。 +最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当回合上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费者。 从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。因此,恢复和 fork 会保留持久目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略表面可以显式调用恢复操作并激活目标。 @@ -36,7 +36,7 @@ Status: implemented ## 测试 -单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 +单元测试固定创建默认值、精确实时 agent 校验、比较并交换拒绝、所有生命周期转换、阻塞原因校验与保留、恢复时的上限执行、清除与替换、种子回放和 `SessionStore.fork()` 继承、会话启动时解除激活与活跃目标重新激活、FIFO 延迟变更协调、重入追加观察、注入拒绝回滚、损坏事件的稳定回放、服务与监听器销毁、监听器隔离、挂钟后退钳制、严格记录解码、生命周期连续性、来源与内容一致性,以及连续目标回合归属。无密钥 Loader/stdio 进程测试通过测试专用 `cordis.yml` 挂载服务与生命周期消费者,再从外部读取持久 JSONL,以验证模型可见快照以及不存在未经请求的目标回合。包源码受仓库逐文件 100% 覆盖率门禁约束。 ## 考虑过的替代方案 @@ -52,7 +52,7 @@ Status: implemented - 恢复与 fork 会暴露同一持久阶段,但在显式恢复变更激活目标前不会执行任何操作。 - 完整快照便于检查和严格回放,但在压缩隐藏它们之前,会在模型历史中重复目标描述与状态字段。 - 修订号与生命周期校验会尽早拒绝遭篡改、部分写入或生产者不一致的目标记录。 -- 回合上限只约束继续执行次数;token、费用、时间和提供方限制仍属于独立策略。 +- 回合上限只约束继续执行次数;当回合、token、费用、时间或提供方限制停止工作时,策略消费者会把它们映射为不同的阻塞原因。 ## 已知限制与延期工作 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index c90eacde5e..92f3b16240 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -471,7 +471,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:166`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7d33e49c13..bf639bed08 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -484,13 +484,6 @@ Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) Goal service (`ctx.goals`) backed exclusively by the owning session log. ```ts cordis-catalog -/** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ -resolveCreate(request: CreateGoalRequest): CreateGoalSpec - /** * Read the current goal for one exact live agent. * @param agent - owning live agent. @@ -546,25 +539,10 @@ complete(agent: Agent, ref: GoalRef): GoalView * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ -block(agent: Agent, ref: GoalRef): GoalView - -/** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ -markUsageLimited(agent: Agent, ref: GoalRef): GoalView - -/** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ -markBudgetLimited(agent: Agent, ref: GoalRef): GoalView +block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView /** * Clear the current goal while retaining a durable tombstone and history. @@ -575,9 +553,9 @@ markBudgetLimited(agent: Agent, ref: GoalRef): GoalView clear(agent: Agent, ref: GoalRef): GoalRef ``` -Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [CreateGoalSpec](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) +Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:104`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:131`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index d7b8b25fb4..6c4ac165f7 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -24,11 +24,21 @@ type GoalPhase = | 'active' | 'paused' | 'blocked' - | 'usage-limited' - | 'budget-limited' | 'complete' ``` +Blocking is the single durable stopped-by-a-problem state. Its policy-owned reason carries a stable lower-kebab-case code for routing and a free-form explanation for humans and models. + +```ts type-equiv +/** Machine-routable and human-readable explanation for a blocked goal. */ +interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} +``` + ```ts type-equiv /** Full durable state written by every non-clear goal mutation. */ interface GoalSnapshot extends GoalRef { @@ -36,6 +46,8 @@ interface GoalSnapshot extends GoalRef { readonly objective: string /** Durable lifecycle phase. */ readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason /** Total admitted goal-round cap. */ readonly maxGoalRounds: number } @@ -98,7 +110,7 @@ interface GoalMessageSource { ## Requests and notifications -Creation separates caller omission from the resolved deployment choice. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. +Creation separates caller omission from the deployment choice, which `create()` resolves internally. An edit is a partial replacement whose runtime validator requires at least one field. Every mutation notification carries the accepted operation and exact revision; clear omits `goal`. ```ts type-equiv /** Input whose omitted round cap is resolved by the service configuration. */ @@ -108,14 +120,6 @@ interface CreateGoalRequest { } ``` -```ts type-equiv -/** Validated create input with every deployment default materialized. */ -interface CreateGoalSpec { - readonly objective: string - readonly maxGoalRounds: number -} -``` - ```ts type-equiv /** Fields changed by an edit; at least one must be present. */ interface EditGoalRequest { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8f1fd333fe..e0d5914f6c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:166`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | - | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/glossary.md b/docs/glossary.md index 0166f01f63..13b800d9a8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -18,7 +18,7 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i ## goal -- **goal** — one durable completion objective attached to an existing session, with a revisioned lifecycle phase and a goal-round cap. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. +- **goal** — one durable completion objective attached to an existing session, with a revisioned `active` / `paused` / `blocked` / `complete` phase and a goal-round cap; `blocked` retains a policy code and explanation. A goal is state, not a scheduler or a separate conversation; the session log remains its source of truth. - **goal round** — one continuation cycle admitted for the current goal. The same-session driver materializes a goal round as one goal-sourced [turn](#turn), which can contain multiple steps; unrelated human turns in the same session do not consume the goal-round cap. - **goal activation** — process-local permission for a continuation consumer to admit another goal round. Activation is either `armed` or `disarmed`; it is deliberately absent from durable replay, so resume and fork require a later explicit resume mutation before automatic work. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 38242445df..ba53f5c0ec 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -254,10 +254,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'goals', summary: 'Goal service (`ctx.goals`) backed exclusively by the owning session log.', methods: [ - { - signature: 'resolveCreate(request: CreateGoalRequest): CreateGoalSpec', - jsDoc: '/**\n * Materialize deployment defaults and validate one create request.\n * @param request - objective plus optional caller-selected round cap.\n * @returns detached, fully resolved create specification.\n */', - }, { signature: 'get(agent: Agent): GoalView | undefined', jsDoc: '/**\n * Read the current goal for one exact live agent.\n * @param agent - owning live agent.\n * @returns a fresh view or `undefined` when no goal is current.\n * @throws {@link GoalError} when the agent is not the registry\'s live instance.\n */', @@ -283,16 +279,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Mark a current non-complete goal complete and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the completed view.\n */', }, { - signature: 'block(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the blocked view.\n */', - }, - { - signature: 'markUsageLimited(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal stopped by an external usage limit.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the usage-limited view.\n */', - }, - { - signature: 'markBudgetLimited(agent: Agent, ref: GoalRef): GoalView', - jsDoc: '/**\n * Mark an active goal stopped at its configured round cap.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @returns the budget-limited view.\n */', + signature: 'block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView', + jsDoc: '/**\n * Mark an active goal blocked and disarm it.\n * @param agent - owning live agent.\n * @param ref - expected current revision.\n * @param reason - policy-owned stable code and human-readable explanation.\n * @returns the blocked view with its durable reason.\n */', }, { signature: 'clear(agent: Agent, ref: GoalRef): GoalRef', @@ -1137,10 +1125,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CreateGoalRequest', declaration: 'export interface CreateGoalRequest {\n readonly objective: string;\n readonly maxGoalRounds?: number;\n}', }, - { - name: 'CreateGoalSpec', - declaration: 'export interface CreateGoalSpec {\n readonly objective: string;\n readonly maxGoalRounds: number;\n}', - }, { name: 'CreateSessionOptions', declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n };\n}', @@ -1241,13 +1225,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalActivation', declaration: 'export type GoalActivation = \'armed\' | \'disarmed\';', }, + { + name: 'GoalBlockReason', + declaration: 'export interface GoalBlockReason {\n readonly code: string;\n readonly message: string;\n}', + }, { name: 'GoalId', declaration: 'export type GoalId = Branded<\'GoalId\'>;', }, { name: 'GoalPhase', - declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'usage-limited\' | \'budget-limited\' | \'complete\';', + declaration: 'export type GoalPhase = \'active\' | \'paused\' | \'blocked\' | \'complete\';', }, { name: 'GoalRef', @@ -1255,7 +1243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'GoalSnapshot', - declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly maxGoalRounds: number;\n}', + declaration: 'export interface GoalSnapshot extends GoalRef {\n readonly objective: string;\n readonly phase: GoalPhase;\n readonly blockedReason?: GoalBlockReason;\n readonly maxGoalRounds: number;\n}', }, { name: 'GoalView', diff --git a/packages/goal/goal/README.md b/packages/goal/goal/README.md index 0e0807f684..175d0d0d79 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -11,13 +11,13 @@ Event-sourced same-session goal state. The service retains one current completio defaultMaxGoalRounds: 256 ``` -`defaultMaxGoalRounds` must be a positive safe integer. `resolveCreate()` materializes this deployment default before `create()` commits a goal; a request-level value overrides it. +`defaultMaxGoalRounds` must be a positive safe integer. `create()` materializes this deployment default internally before committing a goal; a request-level value overrides it. ## Service contract -`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, usage-limit, budget-limit, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). +`ctx.goals` accepts only the exact live `Agent` instance registered under its id. `get()` returns a detached `GoalView`; mutations use a `GoalRef { id, revision }` compare-and-set fence and reject stale refs. The service exposes create, edit, pause, resume, complete, block, and clear verbs through the generated [service catalog](../../../docs/cordis-catalog/services.md). Creation default resolution is an internal implementation step, not an additional public verb. -At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase and activation. Pause, completion, blocking, limit transitions, and clear disarm activation. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; an active armed goal rejects the redundant operation. +At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation. Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The raw `context/message`, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward. @@ -35,7 +35,7 @@ Policy plugins call the service verbs and react to the scoped `goal/changed` eve #### What the model sees -Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. +Each mutation is one raw user-role context block. A snapshot is rendered as `{"goal":...,"roundsStarted":...,"createdAt":...,"updatedAt":...}`; a clear renders the tombstone id/revision and `clearedAt`. There is no hidden state summary outside the log. The descriptive XML delimiter follows this repository's existing `` convention and [Anthropic's published XML-tag prompting guidance](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags); it is public model-experience prior art, not a claim about any provider's proprietary training corpus. #### Token effect diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index d7256a38db..5f80e7fd4c 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -6,6 +6,7 @@ import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' import type { FoldedGoal, + GoalBlockReason, GoalChangeMeta, GoalClearChangeMeta, GoalMessageSource, @@ -25,17 +26,8 @@ const SNAPSHOT_OPERATIONS: ReadonlySet> = new Se 'resume', 'complete', 'block', - 'mark-usage-limited', - 'mark-budget-limited', -]) -const PHASES: ReadonlySet = new Set([ - 'active', - 'paused', - 'blocked', - 'usage-limited', - 'budget-limited', - 'complete', ]) +const PHASES: ReadonlySet = new Set(['active', 'paused', 'blocked', 'complete']) /** Mutable accumulator kept private to the pure fold. */ export interface GoalFoldState { @@ -83,13 +75,24 @@ function nonNegativeInteger(value: unknown, field: string): number { return value } +/** Decode one canonical blocker explanation. */ +function decodeBlockReason(value: unknown): GoalBlockReason { + if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') { + throw new Error('goal change goal.blockedReason has an invalid shape') + } + if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) { + throw new Error('goal change goal.blockedReason.code must be lower-kebab-case') + } + if (typeof value['message'] !== 'string' || value['message'].trim().length === 0 + || value['message'] !== value['message'].trim()) { + throw new Error('goal change goal.blockedReason.message must be non-empty and normalized') + } + return { code: value['code'], message: value['message'] } +} + /** Decode and validate one snapshot. */ function decodeSnapshot(value: unknown): GoalSnapshot { if (!isRecord(value)) throw new Error('goal change goal must be a record') - const keys = Object.keys(value).sort() - if (keys.join(',') !== 'id,maxGoalRounds,objective,phase,revision') { - throw new Error('goal change goal has an invalid shape') - } if (typeof value['id'] !== 'string' || value['id'].length === 0) { throw new Error('goal change goal.id must be a non-empty string') } @@ -100,12 +103,20 @@ function decodeSnapshot(value: unknown): GoalSnapshot { if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) { throw new Error('goal change goal.phase is invalid') } + const phase = value['phase'] as GoalPhase + const expectedKeys = phase === 'blocked' + ? 'blockedReason,id,maxGoalRounds,objective,phase,revision' + : 'id,maxGoalRounds,objective,phase,revision' + if (Object.keys(value).sort().join(',') !== expectedKeys) { + throw new Error('goal change goal has an invalid shape') + } return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'goal.revision'), objective: value['objective'], - phase: value['phase'] as GoalPhase, + phase, maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'), + ...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {}, } } @@ -208,7 +219,10 @@ function validateSnapshotTransition( } switch (change.operation) { case 'edit': - if (next.phase !== current.phase) throw new Error('goal edit cannot change phase') + if (next.phase !== current.phase + || JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) { + throw new Error('goal edit cannot change phase or blocked reason') + } break case 'pause': requireSameDefinition(current, next, change.operation) @@ -220,8 +234,6 @@ function validateSnapshotTransition( 'active', 'paused', 'blocked', - 'usage-limited', - 'budget-limited', ]) if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) { throw new Error('goal resume has an invalid phase transition or exhausted round budget') @@ -236,19 +248,6 @@ function validateSnapshotTransition( requireSameDefinition(current, next, change.operation) if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition') break - case 'mark-usage-limited': - requireSameDefinition(current, next, change.operation) - if (current.phase !== 'active' || next.phase !== 'usage-limited') { - throw new Error('goal mark-usage-limited has an invalid phase transition') - } - break - case 'mark-budget-limited': - requireSameDefinition(current, next, change.operation) - if (current.phase !== 'active' || next.phase !== 'budget-limited' - || state.roundsStarted < next.maxGoalRounds) { - throw new Error('goal mark-budget-limited has an invalid phase transition or remaining round budget') - } - break /* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */ case 'create': throw new Error('goal create cannot be validated as a current-goal transition') diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index c8583894ef..dc485bcc8a 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -27,9 +27,9 @@ import { } from './runtime.ts' import type { CreateGoalRequest, - CreateGoalSpec, EditGoalRequest, GoalActivation, + GoalBlockReason, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, @@ -79,6 +79,12 @@ interface GoalCache { readonly pending: PendingGoalChange[] } +/** Validated create input with every deployment default materialized. */ +interface ResolvedCreateGoal { + readonly objective: string + readonly maxGoalRounds: number +} + /** Validate a caller-visible positive safe-integer round cap. */ function resolveMaxGoalRounds(value: number): number { if (!Number.isSafeInteger(value) || value < 1) { @@ -95,6 +101,31 @@ function resolveObjective(value: string): string { return value.trim() } +/** Materialize deployment defaults and validate one create request. */ +function resolveCreateGoal(request: CreateGoalRequest, defaultMaxGoalRounds: number): ResolvedCreateGoal { + return { + objective: resolveObjective(request.objective), + maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? defaultMaxGoalRounds), + } +} + +/** Validate and detach one policy-owned blocker explanation. */ +function resolveBlockReason(reason: unknown): GoalBlockReason { + const record = typeof reason === 'object' && reason !== null && !Array.isArray(reason) + ? reason as Record + : undefined + const code = record?.['code'] + const message = record?.['message'] + if (typeof code !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code) + || typeof message !== 'string' || message.trim().length === 0) { + throw new GoalError( + 'goal block reason requires a lower-kebab-case code and a non-empty message', + 'GOAL_INVALID_BLOCK_REASON', + ) + } + return { code, message: message.trim() } +} + /** Compare the complete canonical payloads used for deferred reconciliation. */ function sameChange(left: GoalChangeMeta, right: GoalChangeMeta): boolean { return JSON.stringify(left) === JSON.stringify(right) @@ -121,18 +152,6 @@ export class GoalService extends Service { }) } - /** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ - resolveCreate(request: CreateGoalRequest): CreateGoalSpec { - return { - objective: resolveObjective(request.objective), - maxGoalRounds: resolveMaxGoalRounds(request.maxGoalRounds ?? this.resolved.defaultMaxGoalRounds), - } - } - /** * Read the current goal for one exact live agent. * @param agent - owning live agent. @@ -154,7 +173,7 @@ export class GoalService extends Service { * @returns the created live view. */ create(agent: Agent, request: CreateGoalRequest): GoalView { - const spec = this.resolveCreate(request) + const spec = resolveCreateGoal(request, this.resolved.defaultMaxGoalRounds) const cache = this.prepareMutation(agent) const current = cache.state.goal if (current !== undefined && current.phase !== 'complete') { @@ -213,7 +232,7 @@ export class GoalService extends Service { resume(agent: Agent, ref: GoalRef): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) - const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'] + const resumable: readonly GoalPhase[] = ['active', 'paused', 'blocked'] if (!resumable.includes(current.phase)) { throw this.transitionError(current, 'resume', resumable) } @@ -240,7 +259,7 @@ export class GoalService extends Service { agent, ref, 'complete', - ['active', 'paused', 'blocked', 'usage-limited', 'budget-limited'], + ['active', 'paused', 'blocked'], 'complete', 'disarmed', ) @@ -250,45 +269,20 @@ export class GoalService extends Service { * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ - block(agent: Agent, ref: GoalRef): GoalView { - return this.transition(agent, ref, 'block', ['active'], 'blocked', 'disarmed') - } - - /** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ - markUsageLimited(agent: Agent, ref: GoalRef): GoalView { - return this.transition(agent, ref, 'mark-usage-limited', ['active'], 'usage-limited', 'disarmed') - } - - /** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ - markBudgetLimited(agent: Agent, ref: GoalRef): GoalView { + block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView { const cache = this.prepareMutation(agent) const current = this.expectCurrent(cache, ref) if (current.phase !== 'active') { - throw this.transitionError(current, 'mark-budget-limited', ['active']) - } - if (cache.state.roundsStarted < current.maxGoalRounds) { - throw new GoalError( - `goal "${current.id}" has started ${cache.state.roundsStarted}/${current.maxGoalRounds} rounds`, - 'GOAL_INVALID_TRANSITION', - ) + throw this.transitionError(current, 'block', ['active']) } return this.commitCurrent( agent, cache, - 'mark-budget-limited', - this.withPhase(current, 'budget-limited'), + 'block', + { ...this.withPhase(current, 'blocked'), blockedReason: resolveBlockReason(reason) }, 'disarmed', ) } @@ -384,7 +378,13 @@ export class GoalService extends Service { /** Build a new revision with one replacement phase. */ private withPhase(current: GoalSnapshot, phase: GoalPhase): GoalSnapshot { - return { ...current, revision: current.revision + 1, phase } + return { + id: current.id, + revision: current.revision + 1, + objective: current.objective, + phase, + maxGoalRounds: current.maxGoalRounds, + } } /** Shared validated phase transition. */ diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index ef40e997ef..2c6798718d 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -22,16 +22,24 @@ export type GoalPhase = | 'active' | 'paused' | 'blocked' - | 'usage-limited' - | 'budget-limited' | 'complete' +/** Machine-routable and human-readable explanation for a blocked goal. */ +export interface GoalBlockReason { + /** Stable lower-kebab-case classification chosen by the blocking policy. */ + readonly code: string + /** Non-empty explanation shown to humans and models. */ + readonly message: string +} + /** Full durable state written by every non-clear goal mutation. */ export interface GoalSnapshot extends GoalRef { /** Human-requested completion objective. */ readonly objective: string /** Durable lifecycle phase. */ readonly phase: GoalPhase + /** Present exactly while `phase` is `blocked`. */ + readonly blockedReason?: GoalBlockReason /** Total admitted goal-round cap. */ readonly maxGoalRounds: number } @@ -59,8 +67,6 @@ export type GoalOperation = | 'resume' | 'complete' | 'block' - | 'mark-usage-limited' - | 'mark-budget-limited' | 'clear' /** Full-snapshot goal mutation retained in a model-visible context event. */ @@ -121,12 +127,6 @@ export interface CreateGoalRequest { readonly maxGoalRounds?: number } -/** Validated create input with every deployment default materialized. */ -export interface CreateGoalSpec { - readonly objective: string - readonly maxGoalRounds: number -} - /** Fields changed by an edit; at least one must be present. */ export interface EditGoalRequest { readonly objective?: string @@ -149,6 +149,7 @@ export type GoalErrorCode = | 'GOAL_STALE_REVISION' | 'GOAL_INVALID_OBJECTIVE' | 'GOAL_INVALID_MAX_ROUNDS' + | 'GOAL_INVALID_BLOCK_REASON' | 'GOAL_INVALID_EDIT' | 'GOAL_INVALID_TRANSITION' diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 4dcae8938a..eac96e0374 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -111,17 +111,13 @@ function appendRound(session: Session, ref: GoalRef, round: number): void { } describe('GoalService creation and replay', () => { - it('resolves the configured default and writes one balanced raw context snapshot', async () => { + it('applies the configured default and writes one balanced raw context snapshot', async () => { vi.useFakeTimers() vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const seen: string[] = [] ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) - expect(ctx.goals.resolveCreate({ objective: ' finish the feature ' })).toEqual({ - objective: 'finish the feature', - maxGoalRounds: 17, - }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) expect(goal).toMatchObject({ @@ -151,18 +147,19 @@ describe('GoalService creation and replay', () => { vi.useRealTimers() }) - it('uses 256 rounds by default and validates create input at the owning resolver', async () => { + it('uses 256 rounds by default and validates create input inside create', async () => { const { ctx, agent } = await harness() - expect(ctx.goals.resolveCreate({ objective: 'x' })).toEqual({ objective: 'x', maxGoalRounds: 256 }) - expect(() => ctx.goals.resolveCreate({ objective: ' ' })).toThrow(expect.objectContaining({ + expect(() => ctx.goals.create(agent, { objective: ' ' })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_OBJECTIVE', })) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 0 })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) - expect(() => ctx.goals.resolveCreate({ objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1 })).toThrow(GoalError) + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(GoalError) + expect(() => ctx.goals.create(agent, { objective: 'x', maxGoalRounds: 1.5 })).toThrow(HarnessError) + expect(() => ctx.goals.create(agent, { + objective: 'x', maxGoalRounds: Number.MAX_SAFE_INTEGER + 1, + })).toThrow(GoalError) expect(ctx.goals.create(agent, { objective: 'x' }).maxGoalRounds).toBe(256) }) @@ -170,9 +167,10 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const goals = new GoalService(ctx) - expect(goals.resolveCreate({ objective: 'direct' })).toEqual({ - objective: 'direct', - maxGoalRounds: 256, + const stub = stubAgent('goal-direct-construction') + ctx.agents.register(stub.agent) + expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ + objective: 'direct', maxGoalRounds: 256, }) }) @@ -287,18 +285,19 @@ describe('GoalService mutations', () => { })) }) - it('supports pause, resume, block, usage-limit, and completion transitions', async () => { + it('supports pause, resume, block, and completion transitions', async () => { const { ctx, agent } = await harness() let goal = ctx.goals.create(agent, { objective: 'lifecycle' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ phase: 'paused', activation: 'disarmed', revision: 2 }) goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 3 }) - goal = ctx.goals.block(agent, goal) - expect(goal).toMatchObject({ phase: 'blocked', activation: 'disarmed' }) - goal = ctx.goals.resume(agent, goal) - goal = ctx.goals.markUsageLimited(agent, goal) - expect(goal.phase).toBe('usage-limited') + goal = ctx.goals.block(agent, goal, { code: 'needs-input', message: 'A choice is required.' }) + expect(goal).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'needs-input', message: 'A choice is required.' }, + activation: 'disarmed', + }) goal = ctx.goals.resume(agent, goal) goal = ctx.goals.pause(agent, goal) goal = ctx.goals.complete(agent, goal) @@ -307,15 +306,13 @@ describe('GoalService mutations', () => { }) it('allows completion from every stopped phase and replacement only after completion', async () => { - const phases = ['paused', 'blocked', 'usage-limited'] as const + const phases = ['paused', 'blocked'] as const for (const phase of phases) { const { ctx, agent } = await harness() let goal = ctx.goals.create(agent, { objective: phase }) goal = phase === 'paused' ? ctx.goals.pause(agent, goal) - : phase === 'blocked' - ? ctx.goals.block(agent, goal) - : ctx.goals.markUsageLimited(agent, goal) + : ctx.goals.block(agent, goal, { code: 'test-blocker', message: 'Blocked for the test.' }) const complete = ctx.goals.complete(agent, goal) const replacement = ctx.goals.create(agent, { objective: `after ${phase}` }) expect(complete.phase).toBe('complete') @@ -333,32 +330,45 @@ describe('GoalService mutations', () => { expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) const paused = ctx.goals.pause(agent, goal) expect(() => ctx.goals.pause(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) - expect(() => ctx.goals.block(agent, paused)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) - expect(() => ctx.goals.markUsageLimited(agent, paused)).toThrow(expect.objectContaining({ - code: 'GOAL_INVALID_TRANSITION', - })) - expect(() => ctx.goals.markBudgetLimited(agent, paused)).toThrow(expect.objectContaining({ + expect(() => ctx.goals.block(agent, paused, { + code: 'test-blocker', message: 'Blocked for the test.', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION', })) }) - it('enforces the goal-round cap before budget limiting and resuming', async () => { + it('records canonical blocker reasons and enforces the round cap on resume', async () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'bounded', maxGoalRounds: 2 }) + for (const reason of [null, [], { code: 1, message: 'invalid code' }, { code: 'round-limit', message: 1 }]) { + expect(() => ctx.goals.block(agent, goal, reason as never)).toThrow(expect.objectContaining({ + code: 'GOAL_INVALID_BLOCK_REASON', + })) + } + expect(() => ctx.goals.block(agent, goal, { + code: 'Not Canonical', message: 'invalid code', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' })) + expect(() => ctx.goals.block(agent, goal, { + code: 'round-limit', message: ' ', + })).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_BLOCK_REASON' })) appendRound(session, goal, 1) expect(ctx.goals.get(agent)?.roundsStarted).toBe(1) - expect(() => ctx.goals.markBudgetLimited(agent, goal)).toThrow(expect.objectContaining({ - code: 'GOAL_INVALID_TRANSITION', - })) appendRound(session, goal, 2) - goal = ctx.goals.markBudgetLimited(agent, goal) - expect(goal).toMatchObject({ phase: 'budget-limited', roundsStarted: 2, activation: 'disarmed' }) + goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: ' Goal round limit reached. ' }) + expect(goal).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'round-limit', message: 'Goal round limit reached.' }, + roundsStarted: 2, + activation: 'disarmed', + }) expect(() => ctx.goals.resume(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_INVALID_TRANSITION' })) goal = ctx.goals.edit(agent, goal, { maxGoalRounds: 3 }) + expect(goal.blockedReason).toEqual({ code: 'round-limit', message: 'Goal round limit reached.' }) goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', maxGoalRounds: 3, activation: 'armed' }) + expect(goal.blockedReason).toBeUndefined() appendRound(session, goal, 3) - goal = ctx.goals.markBudgetLimited(agent, goal) + goal = ctx.goals.block(agent, goal, { code: 'round-limit', message: 'Goal round limit reached.' }) expect(ctx.goals.complete(agent, goal).phase).toBe('complete') }) @@ -598,7 +608,16 @@ describe('goal replay validation', () => { return { ...current, operation, - goal: { ...current.goal, revision: current.goal.revision + 1, phase }, + goal: { + id: current.goal.id, + revision: current.goal.revision + 1, + objective: current.goal.objective, + phase, + ...phase === 'blocked' + ? { blockedReason: { code: 'test-blocker', message: 'Blocked for replay validation.' } } + : {}, + maxGoalRounds: current.goal.maxGoalRounds, + }, updatedAt: current.updatedAt + 1, ...overrides, } @@ -694,9 +713,6 @@ describe('goal replay validation', () => { mutation(base, 'resume', 'paused'), mutation(base, 'complete', 'active'), mutation(base, 'block', 'active'), - mutation(base, 'mark-usage-limited', 'active'), - mutation(base, 'mark-budget-limited', 'active'), - mutation(base, 'mark-budget-limited', 'budget-limited'), ] for (const change of invalid) expect(() => foldPair(base, change)).toThrow() @@ -782,6 +798,12 @@ describe('goal replay validation', () => { { ...base.goal, objective: ' ' }, { ...base.goal, objective: ' padded ' }, { ...base.goal, phase: 'unknown' }, + { ...base.goal, blockedReason: { code: 'unexpected', message: 'Only blocked goals have reasons.' } }, + { ...base.goal, phase: 'blocked' }, + { ...base.goal, phase: 'blocked', blockedReason: null }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: 'Valid.', extra: true } }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'NOT_CANONICAL', message: 'Bad code.' } }, + { ...base.goal, phase: 'blocked', blockedReason: { code: 'test-blocker', message: ' padded ' } }, { ...base.goal, revision: 0 }, { ...base.goal, maxGoalRounds: -1 }, ] diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 289a2e9778..ac59617e98 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -69,8 +69,8 @@ export const LINK_MAP: Record = { FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', CreateGoalRequest: 'goal.md', - CreateGoalSpec: 'goal.md', EditGoalRequest: 'goal.md', + GoalBlockReason: 'goal.md', GoalChanged: 'goal.md', GoalRef: 'goal.md', GoalView: 'goal.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1092c6c0b0..36b1e3816f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -29,13 +29,13 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalRef", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalPhase", "source": "packages/goal/goal/src/types.ts" }, + { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalBlockReason", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshot", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", "source": "packages/goal/goal/src/types.ts" }, - { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalSpec", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index ebe71dc559..6c7bcb3603 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -543,7 +543,7 @@ Goal mutation accepted by one live agent. The matching context event is already - `agent` — agent whose session owns the goal. - `change` — fresh current projection or clear tombstone. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L166) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/types.ts#L167) ## llm/* diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 8789074e36..38c2daaafc 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,26 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L104) - -### ctx.goals.resolveCreate(request) - -```ts website-api -/** - * Materialize deployment defaults and validate one create request. - * @param request - objective plus optional caller-selected round cap. - * @returns detached, fully resolved create specification. - */ -resolveCreate(request: CreateGoalRequest): CreateGoalSpec -``` - -Materialize deployment defaults and validate one create request. - -- `request` — objective plus optional caller-selected round cap. - -**Returns** detached, fully resolved create specification. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L129) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L131) ### ctx.goals.get(agent) @@ -45,7 +26,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L157) ### ctx.goals.create(agent, request) @@ -67,7 +48,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L171) ### ctx.goals.edit(agent, ref, request) @@ -90,7 +71,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L181) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L196) ### ctx.goals.pause(agent, ref) @@ -111,7 +92,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L202) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L217) ### ctx.goals.resume(agent, ref) @@ -133,7 +114,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L213) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L228) ### ctx.goals.complete(agent, ref) @@ -154,70 +135,30 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L238) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L253) -### ctx.goals.block(agent, ref) +### ctx.goals.block(agent, ref, reason) ```ts website-api /** * Mark an active goal blocked and disarm it. * @param agent - owning live agent. * @param ref - expected current revision. - * @returns the blocked view. + * @param reason - policy-owned stable code and human-readable explanation. + * @returns the blocked view with its durable reason. */ -block(agent: Agent, ref: GoalRef): GoalView +block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView ``` Mark an active goal blocked and disarm it. - `agent` — owning live agent. - `ref` — expected current revision. +- `reason` — policy-owned stable code and human-readable explanation. -**Returns** the blocked view. +**Returns** the blocked view with its durable reason. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L255) - -### ctx.goals.markUsageLimited(agent, ref) - -```ts website-api -/** - * Mark an active goal stopped by an external usage limit. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the usage-limited view. - */ -markUsageLimited(agent: Agent, ref: GoalRef): GoalView -``` - -Mark an active goal stopped by an external usage limit. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the usage-limited view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L265) - -### ctx.goals.markBudgetLimited(agent, ref) - -```ts website-api -/** - * Mark an active goal stopped at its configured round cap. - * @param agent - owning live agent. - * @param ref - expected current revision. - * @returns the budget-limited view. - */ -markBudgetLimited(agent: Agent, ref: GoalRef): GoalView -``` - -Mark an active goal stopped at its configured round cap. - -- `agent` — owning live agent. -- `ref` — expected current revision. - -**Returns** the budget-limited view. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L271) ### ctx.goals.clear(agent, ref) @@ -238,4 +179,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L302) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L292) From a625ef4fe9295a205767fdcd675fc1d254898dbb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:47:56 +0800 Subject: [PATCH 082/132] docs(compact): qualify pruning cache reuse --- packages/compact/compact-tool-result-prune/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index d639741c51..94f4e4f45e 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -49,7 +49,7 @@ Each rewritten tool result has at most `thresholdChars` text code points. Prunin #### KV Cache effect -Replacing an earlier result invalidates the reusable request prefix from that result onward; subsequent requests reuse the new pruned prefix until another surface replacement occurs. +Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical. ## Known Limitations and Deferred Work From 63d1f7679cbc6bbbdec710f5660fdade1a625145 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:50:29 +0800 Subject: [PATCH 083/132] docs(goal): refresh generated service references --- docs/cordis-catalog/services.md | 2 +- website/zh-CN/api/harness/goals.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bf639bed08..67884e71c2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -555,7 +555,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:131`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts) ## `ctx.llm` — `LlmService` diff --git a/website/zh-CN/api/harness/goals.md b/website/zh-CN/api/harness/goals.md index 38c2daaafc..ee86d7be62 100644 --- a/website/zh-CN/api/harness/goals.md +++ b/website/zh-CN/api/harness/goals.md @@ -6,7 +6,7 @@ Goal service (`ctx.goals`) backed exclusively by the owning session log. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L131) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L135) ### ctx.goals.get(agent) @@ -26,7 +26,7 @@ Read the current goal for one exact live agent. **Returns** a fresh view or `undefined` when no goal is current. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L157) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L161) ### ctx.goals.create(agent, request) @@ -48,7 +48,7 @@ Create and arm a goal. A completed goal may be replaced; every other current pha **Returns** the created live view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L171) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L175) ### ctx.goals.edit(agent, ref, request) @@ -71,7 +71,7 @@ Edit objective and/or round cap without changing phase. **Returns** the edited view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L196) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L200) ### ctx.goals.pause(agent, ref) @@ -92,7 +92,7 @@ Pause an active goal and disarm automatic continuation. **Returns** the paused view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L217) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L221) ### ctx.goals.resume(agent, ref) @@ -114,7 +114,7 @@ Resume and arm a stopped goal, or rearm an active goal after a session-start edg **Returns** the active view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L228) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L232) ### ctx.goals.complete(agent, ref) @@ -135,7 +135,7 @@ Mark a current non-complete goal complete and disarm it. **Returns** the completed view. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L253) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L257) ### ctx.goals.block(agent, ref, reason) @@ -158,7 +158,7 @@ Mark an active goal blocked and disarm it. **Returns** the blocked view with its durable reason. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L271) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L275) ### ctx.goals.clear(agent, ref) @@ -179,4 +179,4 @@ Clear the current goal while retaining a durable tombstone and history. **Returns** the tombstone ref whose revision is one past the cleared snapshot. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L292) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/goal/goal/src/index.ts#L296) From 15640ca697b5bfcee514faad467a1584a6b0cf01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:53:10 +0800 Subject: [PATCH 084/132] fix(goal): require explained model blockers --- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../2026-07-19-model-facing-goal-tools.md | 8 +- .../2026-07-19-model-facing-goal-tools.zh.md | 8 +- docs/tool-catalog.md | 8 +- .../tests/fixtures/goal/tool-goal/cordis.yml | 26 ---- .../fixtures/goal/tool-goal/scripted-llm.ts | 88 ------------ .../headless-agent/goal.cordis.snapshot.yml | 13 ++ examples/headless-agent/goal.cordis.yml | 12 ++ .../headless-agent/tests/headless.snapshot.ts | 109 ++++++++++++--- .../tests/snapshots/goal-tools/input.json | 9 ++ .../snapshots/goal-tools/replay.override.json | 33 +++++ .../goal-tools/stream-json.expected.jsonl | 34 +++++ knip.json | 1 - packages/goal/tool-goal/README.md | 8 +- packages/goal/tool-goal/src/index.ts | 34 ++++- .../goal/tool-goal/tests/tool-goal.e2e.ts | 127 ------------------ .../goal/tool-goal/tests/tool-goal.spec.ts | 54 +++++++- 17 files changed, 289 insertions(+), 287 deletions(-) delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml delete mode 100644 examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts create mode 100644 examples/headless-agent/goal.cordis.snapshot.yml create mode 100644 examples/headless-agent/goal.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/input.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/replay.override.json create mode 100644 examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl delete mode 100644 packages/goal/tool-goal/tests/tool-goal.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index f857c5360e..fd97351a0e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: e37eaabecfd1984e1198c26a460e78a92375dac1 -2026-07-19-model-facing-goal-tools.zh.md: c0d289271d2a8053193299c16a2bc9477f2f1038 +2026-07-19-model-facing-goal-tools.md: 2ef77b53cd8b95c9cdffd12e20c723fb1cff3a5d +2026-07-19-model-facing-goal-tools.zh.md: 08619600355c71ccd30d608e3c4e5a7fba753d4e diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index e37eaabecf..2ef77b53cd 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -16,9 +16,9 @@ The surface also needs to preserve the separation between durable state and live ### Tools and model contract -`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`. +`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. -The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker. +The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition. All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state. @@ -34,11 +34,11 @@ Complete and blocked accept either direct-human authority or the exact current g ### Blocking threshold -`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. +`blockedAfterConsecutiveRounds` is a validated positive safe-integer configuration with default `3`. When an autonomous goal round calls `blocked`, the plugin mechanically requires at least that many admitted rounds and a non-empty explanation; the configured value also appears in model guidance. The runtime cannot determine whether those rounds encountered the same blocking condition, so semantic equivalence remains a model judgment. This count is deliberately separate from the goal's generous continuation cap. ## Testing -Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless Loader/stdio process test mounts the real goal, tool, loop, and persistence plugins through `cordis.yml`, drives scripted model tool calls through a human pause and assistant acknowledgment, and reads the JSONL externally to verify the model-visible create/pause snapshots, structured tool results, and configured prompt text. +Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index c0d289271d..0861960035 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -16,9 +16,9 @@ Status: implemented ### 工具与模型契约 -`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效。 +`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。 -提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞。 +提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。 @@ -34,11 +34,11 @@ Status: implemented ### 阻塞阈值 -`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 +`blockedAfterConsecutiveRounds` 是经过校验的正安全整数配置,默认值为 `3`。自主目标回合调用 `blocked` 时,插件会机械地要求至少已经接纳该数量的回合并提供非空说明;配置值也会出现在模型指导中。运行时无法判断这些回合是否遇到了语义上相同的阻塞条件,因此语义等价性仍由模型判断。该计数特意与目标的宽裕继续执行上限分离。 ## 测试 -单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥 Loader/stdio 进程测试通过 `cordis.yml` 挂载真实的目标、工具、循环和持久化插件,驱动脚本化模型工具调用经过人类暂停与智能体确认,并从外部读取 JSONL,以验证模型可见的创建/暂停快照、结构化工具结果和配置后的提示词文本。 +单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal` 和 `get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。 ## 考虑过的替代方案 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index febcf6a4bb..0244e39629 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -423,7 +423,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `get_goal` -Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. +Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. ```json { @@ -436,7 +436,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ ### `update_goal` -Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. +Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. ```json { @@ -468,6 +468,10 @@ Update the exact current goal revision. edit, pause, and resume require a direct "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml b/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml deleted file mode 100644 index fa63160399..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Test-only composition: drive all three goal tools through a real root agent. -- id: scripted-llm - name: './scripted-llm.ts' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: goal - name: '@deepseek-ai/dsh-goal' - config: - defaultMaxGoalRounds: 11 - -- id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - config: - blockedAfterConsecutiveRounds: 3 - -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: goal-script - model: goal-script - persona: 'Execute the deterministic goal-tool composition test.' - welcome: 'goal-tools e2e ready.' - persistenceRoot: './.sessions' - workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts b/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts deleted file mode 100644 index 310737050d..0000000000 --- a/examples/echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** Deterministic adapter that creates, reads, pauses, then acknowledges one goal. */ - -import type { Context } from 'cordis' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' - -interface GoalState { - readonly id: string - readonly revision: number -} - -/** Text from the latest ordinary user message, excluding raw goal-state context. */ -function latestPrompt(messages: readonly Message[]): { index: number; text: string } { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index] - if (message?.role !== 'user') continue - const text = message.content - .filter(block => block.type === 'text' && !block.text.startsWith('')) - .map(block => block.type === 'text' ? block.text : '') - .join('\n') - if (text.length > 0) return { index, text } - } - return { index: -1, text: '' } -} - -/** Parse the latest domain snapshot rendered into history. */ -function latestGoal(messages: readonly Message[]): GoalState | undefined { - for (const message of [...messages].reverse()) { - for (const block of [...message.content].reverse()) { - if (block.type !== 'text' || !block.text.startsWith('')) continue - const json = block.text.slice(''.length, -''.length) - const value = JSON.parse(json) as { goal?: GoalState } - if (value.goal !== undefined) return value.goal - } - } - return undefined -} - -/** Names of tool calls recorded after the latest ordinary prompt. */ -function callsAfter(messages: readonly Message[], index: number): string[] { - return messages.slice(index + 1).flatMap(message => message.content) - .filter(block => block.type === 'tool-call') - .map(block => block.type === 'tool-call' ? block.name : '') -} - -/** Emit one tool-call response. */ -async function* toolCall(name: string, args: object): AsyncIterable { - const id = CallId(`call-${name}`) - const raw = JSON.stringify(args) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: raw } - yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: raw } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } -} - -/** Emit one terminal text response. */ -async function* textReply(text: string): AsyncIterable { - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'finish', reason: { kind: 'stop' } } -} - -class GoalScriptAdapter extends LlmAdapter { - override stream(options: GenerateOptions): AsyncIterable { - const prompt = latestPrompt(options.messages) - const calls = callsAfter(options.messages, prompt.index) - if (prompt.text === 'start' && !calls.includes('create_goal')) { - return toolCall('create_goal', { objective: 'Finish the composed goal-tool proof', max_goal_rounds: 7 }) - } - if (prompt.text === 'start' && !calls.includes('get_goal')) return toolCall('get_goal', {}) - if (prompt.text === 'start') return textReply('GOAL CREATED') - if (prompt.text === 'pause' && !calls.includes('update_goal')) { - const goal = latestGoal(options.messages) - if (goal === undefined) throw new Error('scripted goal state missing') - return toolCall('update_goal', { goal_id: goal.id, revision: goal.revision, action: 'pause' }) - } - if (prompt.text === 'pause') return textReply('GOAL PAUSED') - return textReply('UNEXPECTED PROMPT') - } -} - -export const name = 'goal-tool-scripted-llm' -export const inject = ['llm'] - -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['goal-script'], new GoalScriptAdapter()) -} diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml new file mode 100644 index 0000000000..185d3dd11e --- /dev/null +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -0,0 +1,13 @@ +# Replay counterpart to goal.cordis.yml; only the live model is replaced. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./goal.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + diff --git a/examples/headless-agent/goal.cordis.yml b/examples/headless-agent/goal.cordis.yml new file mode 100644 index 0000000000..01f1726100 --- /dev/null +++ b/examples/headless-agent/goal.cordis.yml @@ -0,0 +1,12 @@ +# Add the persisted goal domain and its model-facing tools to the real one-shot app. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index dc448b9b23..d799c6250d 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -11,10 +11,12 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') -const scenarioDir = join(snapshotsDir, 'advanced-toolchain') -const sessionFixture = join(scenarioDir, 'session.jsonl') -const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl') -const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain') +const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl') +const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl') +const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const goalScenarioDir = join(snapshotsDir, 'goal-tools') +const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -70,12 +72,36 @@ function normalizeHeadlessStream(rawStdout: string, cwd: string): string { return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) } -async function advancedPrompt(): Promise { - const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as { +/** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */ +function normalizeGoalTimestamps(value: unknown): unknown { + if (typeof value === 'string') { + return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10') + } + if (Array.isArray(value)) return value.map(normalizeGoalTimestamps) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number' + ? 0 + : normalizeGoalTimestamps(item), + ])) + } + return value +} + +/** Normalize the stream's durable goal timestamps after the shared scrubbers. */ +function normalizeGoalStream(rawStdout: string, cwd: string): string { + return parseJsonl(normalizeHeadlessStream(rawStdout, cwd)) + .map(record => JSON.stringify(normalizeGoalTimestamps(record))) + .join('\n') + '\n' +} + +async function scenarioPrompt(dir: string, label: string): Promise { + const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as { steps?: { op?: unknown; text?: unknown }[] } const prompt = input.steps?.find(step => step.op === 'prompt')?.text - if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step') + if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`) return prompt } @@ -90,24 +116,27 @@ async function persistedLogs(cwd: string): Promise { describe('headless stream-json snapshots', () => { it('replays the advanced toolchain through the one-shot app', async () => { - const prompt = await advancedPrompt() + const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const expectedSessions = await Promise.all([ - sessionFixture, - join(scenarioDir, 'session.1.jsonl'), - join(scenarioDir, 'session.2.jsonl'), + advancedSessionFixture, + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), ].map(file => readFile(file, 'utf8'))) let runCwd = '' const result = await runLoaderSmoke({ label: 'advanced headless stream-json snapshot', tempDirPrefix: 'headless-snapshot-advanced-', binScript, - configPath, - binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt], + configPath: advancedConfigPath, + binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt], tsconfigPath, env: { DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: sessionFixture, - DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter), + DSH_SNAPSHOT_FILE: advancedSessionFixture, + DSH_SNAPSHOT_CHILD_FILES: [ + join(advancedScenarioDir, 'session.1.jsonl'), + join(advancedScenarioDir, 'session.2.jsonl'), + ].join(delimiter), NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), }, prepare: (cwd) => { runCwd = cwd }, @@ -134,6 +163,56 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(advancedStreamExpected, normalized) + expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('replays persisted goal tools through the one-shot app', async () => { + const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools') + const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'goal tools headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-goal-tools-', + binScript, + configPath: goalConfigPath, + binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'), + DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const calls = records.filter(record => record.type === 'tool/call') + .map(record => (record.data as JsonObject | undefined)?.name) + expect(calls).toEqual(['create_goal', 'get_goal']) + const goalChanges = records.filter((record) => { + if (record.type !== 'context/message') return false + const data = record.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + return meta?.kind === 'goal/change' + }) + expect(goalChanges).toHaveLength(1) + const data = goalChanges[0]?.data as JsonObject | undefined + const meta = data?.meta as JsonObject | undefined + const goal = meta?.goal as JsonObject | undefined + expect(meta?.operation).toBe('create') + expect(goal).toMatchObject({ + objective: 'Finish the headless goal-tool snapshot proof', + phase: 'active', + maxGoalRounds: 7, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeGoalStream(result.stdout, runCwd) if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/examples/headless-agent/tests/snapshots/goal-tools/input.json b/examples/headless-agent/tests/snapshots/goal-tools/input.json new file mode 100644 index 0000000000..cb0b3bbd82 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/input.json @@ -0,0 +1,9 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Create a durable goal to finish the snapshot proof, then inspect it." + } + ] +} + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json new file mode 100644 index 0000000000..7e4fd90c6c --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/replay.override.json @@ -0,0 +1,33 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_get", "name": "get_goal", "argumentsDelta": "{}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_get", "name": "get_goal", "arguments": "{}" } }, + { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 35, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] + diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl new file mode 100644 index 0000000000..b6e9ad7e70 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -0,0 +1,34 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal to finish the snapshot proof, then inspect it."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"context/message","seq":12,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"envelope":"raw","meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_get","name":"get_goal","argumentsDelta":"{}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"GOAL READY"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":35,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"GOAL READY","reason":{"kind":"completed"},"usage":{"inputTokens":85,"outputTokens":14}} diff --git a/knip.json b/knip.json index eb4eff3a9f..9b7b511c44 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,6 @@ "entry": [ "echo-agent/src/*.ts", "echo-agent/tests/fixtures/goal/goal/seed-goal.ts", - "echo-agent/tests/fixtures/goal/tool-goal/scripted-llm.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 1ac03ac04a..3b97d7aa82 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -4,9 +4,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal ## Tools -- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, and current process-local activation. +- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation. - `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution. -- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`. +- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. @@ -18,7 +18,7 @@ Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` in `{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. -Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted. Direct human authority may stop a goal immediately. +Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately. ## Config @@ -42,7 +42,7 @@ A fixed goal policy says when semantic human intent warrants creation, requires ##### Goal policy ```markdown -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. ``` #### Token effect diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index cd105b3705..075264f93e 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -51,7 +51,8 @@ const CREATE_DESCRIPTION = const GET_DESCRIPTION = 'Read the current same-session goal, including its exact id/revision, objective, phase, completed ' - + 'continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.' + + 'continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. ' + + 'Call this before updating a goal.' /** Render policy guidance with its deployment-selected blocked threshold. */ function guidance(blockedAfter: number): string { @@ -62,7 +63,8 @@ function guidance(blockedAfter: number): string { + 'a human asks to continue or resume in any wording or language, use update_goal action ' + 'resume to rearm it. Mark complete only when the objective is actually achieved. Mark ' + `blocked only after the same blocking condition persists for at least ${blockedAfter} ` - + 'consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked.' + + 'consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, ' + + 'or useful remaining work is not blocked.' } /** Validate config even when apply is called directly outside Loader normalization. */ @@ -97,6 +99,7 @@ function renderGoal(goal: GoalView | undefined): string { phase: goal.phase, roundsStarted: goal.roundsStarted, maxGoalRounds: goal.maxGoalRounds, + ...goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }, }, activation: goal.activation, }) @@ -183,7 +186,7 @@ export function apply(ctx: Context, config: Config): void { description: 'Update the exact current goal revision. edit, pause, and resume require a direct ' + 'top-level human request. During an automatic continuation of the current goal, complete ' + 'and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains ' - + 'responsible for judging that the same condition persisted across those rounds.', + + 'responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.', parameters: { goal_id: { type: 'string', required: true, description: 'Exact id returned by get_goal.' }, revision: { type: 'number', required: true, description: 'Exact positive revision returned by get_goal.' }, @@ -195,6 +198,10 @@ export function apply(ctx: Context, config: Config): void { }, objective: { type: 'string', description: 'Replacement objective; valid only with action edit.' }, max_goal_rounds: { type: 'number', description: 'Replacement cap; valid only with action edit.' }, + blocked_reason: { + type: 'string', + description: 'Concrete blocking condition; required only with action blocked.', + }, }, execute(args, exec) { const execution = goalToolExecution(ctx, exec) @@ -205,6 +212,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'edit') { requireDirectHuman(ctx, execution) + if (args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } const goal = ctx.goals.edit(execution.agent, ref, replacements) observeMutation(terminalTurns, execution, false) return Promise.resolve([{ @@ -214,9 +224,9 @@ export function apply(ctx: Context, config: Config): void { } if (args.action === 'pause' || args.action === 'resume') { requireDirectHuman(ctx, execution) - if (args.objective !== undefined || args.max_goal_rounds !== undefined) { + if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) { throw new HarnessError( - 'objective and max_goal_rounds are valid only with action edit', + 'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE', ) } @@ -233,6 +243,13 @@ export function apply(ctx: Context, config: Config): void { 'GOAL_TOOL_INVALID_UPDATE', ) } + if (args.action === 'complete' && args.blocked_reason !== undefined) { + throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } + if (args.action === 'blocked' + && (args.blocked_reason === undefined || args.blocked_reason.trim().length === 0)) { + throw new HarnessError('blocked_reason is required with action blocked', 'GOAL_TOOL_INVALID_UPDATE') + } if (args.action === 'blocked' && authority.kind === 'goal-round' && authority.goal.roundsStarted < resolved.blockedAfterConsecutiveRounds) { throw new HarnessError( @@ -243,14 +260,17 @@ export function apply(ctx: Context, config: Config): void { } const goal = args.action === 'complete' ? ctx.goals.complete(execution.agent, ref) - : ctx.goals.block(execution.agent, ref) + : ctx.goals.block(execution.agent, ref, { + code: 'model-reported', + message: args.blocked_reason as string, + }) observeMutation(terminalTurns, execution, authority.kind === 'goal-round') return Promise.resolve([{ type: 'text', text: renderGoal(goal) }]) }, presentCall: args => present( `${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`, 'other', - args.objective ?? args.goal_id, + args.blocked_reason ?? args.objective ?? args.goal_id, ), })) } diff --git a/packages/goal/tool-goal/tests/tool-goal.e2e.ts b/packages/goal/tool-goal/tests/tool-goal.e2e.ts deleted file mode 100644 index b19a4ee851..0000000000 --- a/packages/goal/tool-goal/tests/tool-goal.e2e.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' -import { decodeGoalChange } from '@deepseek-ai/dsh-goal' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' - -const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/goal/tool-goal/cordis.yml', - import.meta.url, -)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const PROCESS_TIMEOUT_MS = 30_000 -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const PAUSED_RESULT = '"phase":"paused"' - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -async function runComposition(): Promise<{ stdout: string; stderr: string }> { - workdir = await mkdtemp(join(tmpdir(), 'goal-tools-e2e-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [configPath], - tsconfigPath: repoTsconfig, - exposeInternals: true, - env: { - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - const proc = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stdout = '' - let stderr = '' - let pauseSent = false - let inputClosed = false - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { - stdout += chunk - if (!pauseSent && stdout.includes('GOAL CREATED') && stdout.includes('\n> ')) { - pauseSent = true - proc.stdin.write('pause\n') - } - const pausedAt = stdout.indexOf(PAUSED_RESULT) - if (!inputClosed && pausedAt >= 0 && stdout.indexOf('\n> ', pausedAt) >= 0) { - inputClosed = true - proc.stdin.end() - } - }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error( - `goal-tools e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`, - )) - }, PROCESS_TIMEOUT_MS) - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`goal-tools e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - proc.on('error', (error) => { clearTimeout(timer); reject(error) }) - proc.stdin.write('start\n') - }) -} - -describe('goal tools through a real Loader, app, and stdio process', () => { - it('creates, reads, and pauses one root goal with durable tool and state records', async () => { - const { stdout, stderr } = await runComposition() - expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('goal-tools e2e ready.') - expect(stdout).toContain('GOAL CREATED') - expect(stdout).toContain(PAUSED_RESULT) - expect(stdout).toContain('GOAL PAUSED') - - const logs = await jsonlFiles(join(workdir as string, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.map(event => event.data.name)).toEqual(['create_goal', 'get_goal', 'update_goal']) - const results = events.filter(event => event.type === 'tool/result') - expect(results).toHaveLength(3) - expect(results.every(event => !event.data.isError)).toBe(true) - - const changes = events - .filter(event => event.type === 'context/message' && event.data.source.kind === 'goal') - .map(event => event.type === 'context/message' ? decodeGoalChange(event.data.meta) : undefined) - expect(changes.map(change => change?.operation)).toEqual(['create', 'pause']) - expect(changes[1]).toMatchObject({ goal: { phase: 'paused', revision: 2, maxGoalRounds: 7 } }) - expect(JSON.stringify(changes)).not.toContain('activation') - - const headers = events.filter(event => event.type === 'request/header') - expect(JSON.stringify(headers)).toContain('infer goal intent') - expect(JSON.stringify(headers)).toContain('at least 3 consecutive goal rounds') - }, TEST_TIMEOUT_MS) -}) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 044fe2369a..cd45c145c7 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -135,8 +135,8 @@ describe('goal tool registration and presentation', () => { card: 'generic', title: 'Create goal', kind: 'other', rawInput: 'ship', }) expect(ctx.tools.get('update_goal')?.presentCall?.({ - goal_id: 'goal-1', revision: 2, action: 'blocked', - })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'goal-1' }) + goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.', + })).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' }) expect(ctx.tools.get('update_goal')?.presentCall?.({ goal_id: 'goal-1', revision: 2, action: 'resume', })).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' }) @@ -391,6 +391,26 @@ describe('goal tool state transitions', () => { max_goal_rounds: 2, }, root.agent) expect(terminalUpdate.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithoutReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', + }, root.agent) + expect(blockedWithoutReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const blockedWithEmptyReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'blocked', blocked_reason: ' ', + }, root.agent) + expect(blockedWithEmptyReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const completeWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', blocked_reason: 'Not a blocker.', + }, root.agent) + expect(completeWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') + const editWithReason = await execute(ctx, 'update_goal', { + goal_id: created.id, + revision: created.revision, + action: 'edit', + objective: 'still valid', + blocked_reason: 'Not valid for edit.', + }, root.agent) + expect(editWithReason.error?.code).toBe('GOAL_TOOL_INVALID_UPDATE') const malformedRef = await execute(ctx, 'update_goal', { goal_id: '', revision: 0, action: 'edit', objective: 'x', }, root.agent) @@ -423,16 +443,26 @@ describe('goal tool state transitions', () => { for (let round = 1; round <= 2; round += 1) { turn = openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round }) const result = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) expect(result.error?.code).toBe('GOAL_TOOL_BLOCK_THRESHOLD') closeTurn(root, turn) } openTurn(root, { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 3 }) const blocked = await execute(ctx, 'update_goal', { - goal_id: ref.id, revision: ref.revision, action: 'blocked', + goal_id: ref.id, + revision: ref.revision, + action: 'blocked', + blocked_reason: 'The required credential is still unavailable.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 3 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, + roundsStarted: 3, + }) }) it('lets direct human authority block before the model threshold', async () => { @@ -440,8 +470,18 @@ describe('goal tool state transitions', () => { openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'human stop' }) const blocked = await execute(ctx, 'update_goal', { - goal_id: created.id, revision: created.revision, action: 'blocked', + goal_id: created.id, + revision: created.revision, + action: 'blocked', + blocked_reason: 'The user asked to stop until a prerequisite is available.', }, root.agent) - expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked', roundsStarted: 0 }) + expect(resultGoal(blocked)).toMatchObject({ + phase: 'blocked', + blockedReason: { + code: 'model-reported', + message: 'The user asked to stop until a prerequisite is available.', + }, + roundsStarted: 0, + }) }) }) From 7aee299bdad88af70f9fe6b38a86e9da287c55f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:02:28 +0800 Subject: [PATCH 085/132] docs(compact): correct pruning surface contracts --- docs/cordis-catalog/services.md | 2 ++ docs/core-data-structures/compaction.md | 2 +- packages/compact/compact-tool-result-prune/README.md | 2 ++ packages/compact/compact-tool-result-prune/src/index.ts | 2 ++ packages/compact/compact/README.md | 2 +- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- website/zh-CN/api/harness/tool-result-prune.md | 4 +++- 7 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 89cdaff0de..2707809145 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1147,6 +1147,8 @@ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null * and points at the shadowed node for durable provenance and replay. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. + * @throws when the session rejects a replacement; replacements committed + * earlier in the pass remain durable. */ pruneSession(session: Session): PruneResult ``` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 6db80273c8..bb302ff52b 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -6,7 +6,7 @@ Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact ## The `compact/*` session events -Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the compaction lock and its provenance, and never join the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', start, end }` — the only surface mutation. See the Agent Note for why reusing `user/message` is honest rather than a workaround. +Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the compaction lock and its provenance, and never join the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', start, end }` — the only surface mutation performed by summary compaction. See the Agent Note for why reusing `user/message` is honest rather than a workaround. | Event | Payload | Role | |---|---|---| diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index 94f4e4f45e..c06eab5405 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -8,6 +8,8 @@ This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md `pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection. +The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable. + `measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster. Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement. diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 641840c67b..d4a2daecbc 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -118,6 +118,8 @@ export class ToolResultPruneService extends Service { * and points at the shadowed node for durable provenance and replay. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. + * @throws when the session rejects a replacement; replacements committed + * earlier in the pass remain durable. */ pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 8cfba13437..6e33b6e570 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, 3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope, -4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**, +4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**, 5. appends `compact/end` (log-only) — releases the lock. The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e775f371e0..58d58ee3bb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -541,7 +541,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'pruneSession(session: Session): PruneResult', - jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n */', + jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', }, ], }, diff --git a/website/zh-CN/api/harness/tool-result-prune.md b/website/zh-CN/api/harness/tool-result-prune.md index c9ab9306ab..28042240b1 100644 --- a/website/zh-CN/api/harness/tool-result-prune.md +++ b/website/zh-CN/api/harness/tool-result-prune.md @@ -68,6 +68,8 @@ Replace an over-budget text middle while retaining rich-block order. Text slicin * and points at the shadowed node for durable provenance and replay. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. + * @throws when the session rejects a replacement; replacements committed + * earlier in the pass remain durable. */ pruneSession(session: Session): PruneResult ``` @@ -78,4 +80,4 @@ Prune every over-budget tool result from one stable current-surface snapshot. Ea **Returns** landed replacements and aggregate Unicode-code-point savings. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact-tool-result-prune/src/index.ts#L122) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact-tool-result-prune/src/index.ts#L124) From 1a1ff56ab7410bfa6e4bd34ba3ee01db36b1dfad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:12:14 +0800 Subject: [PATCH 086/132] fix(tool-subagent): default maxDepth to 3 --- .../2026-07-12-subagent-persona-tool-filter-and-depth.md | 6 ++++-- docs/config-catalog.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 4 ++-- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 4 ++-- .../subagent/tool-subagent/tests/tool-subagent.spec.ts | 9 +++++---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 29f77364fa..6e4b388a26 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -49,9 +49,11 @@ The global registry remains live. A deny-only filter admits a later global name The depth limit bounds recursive delegation independently of tool visibility. A top-level agent has depth zero; an in-process child has its parent's validated depth plus one. `maxDepth` is an absolute non-negative safe integer, and a start rejects before child ownership begins when the derived child depth is greater than the cap. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. Omitting the cap leaves depth unbounded by this mechanism. +The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -A deployment can combine depth and filtering. For example, it may keep the delegation tool visible at depth one but set `maxDepth: 1`, or deny the delegation tool entirely in children. Neither choice changes the provider's conversation-history behavior. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. + +A deployment can combine depth and filtering. When a numeric tool cap and `toolFilter` are supported, `dsh-tool-subagent` denies its configured tool name in a child whose derived depth is at the cap; the provider's independent depth check still rejects direct or alternate starts beyond it. A deployment may also deny delegation tools entirely in children. Neither choice changes the provider's conversation-history behavior. ### Capability gating keeps providers honest diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 07546946e2..d88718229c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1180,7 +1180,7 @@ export interface Config { deny?: string[] } /** - * Maximum child depth: a non-negative safe integer (default `1`; `0` forbids + * Maximum child depth: a non-negative safe integer (default `3`; `0` forbids * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap * requires the provider's `depthLimit` capability (mount fails loud * otherwise), and a child AT the cap additionally loses this tool from its diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 452e0e6d19..849c2230d4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -105,7 +105,7 @@ const SCENARIOS: Scenario[] = [ }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true }, - // Children sit AT the default depth cap (maxDepth 1), so each child's header + // Children sit at this example's configured depth cap (`maxDepth: 1`), so each child's header // legitimately omits the delegation tool that spawned it (schema hiding). { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, { name: 'subagent-multi', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, @@ -125,7 +125,7 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'advanced', configPath: ADVANCED_CONFIG, - // The direct spawn child sits AT the default cap and loses `subagent`; + // The direct spawn child sits at the configured cap and loses `subagent`; // workflow children bypass tool-subagent and keep the full set. childToolOmissions: ['subagent'], }, diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 3a0c2c4139..8121531a7b 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -22,7 +22,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before | `agentOptions` | Default child options, currently including `model`. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | -| `maxDepth` | Absolute delegation-depth cap, default `1` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap — for an out-of-process provider whose budget belongs to the child harness. A child AT the cap also loses this tool from its schema when the provider supports `toolFilter` (prompt-face hiding; the service still rejects on the execution face). | +| `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap — for an out-of-process provider whose budget belongs to the child harness. A child AT the cap also loses this tool from its schema when the provider supports `toolFilter` (prompt-face hiding; the service still rejects on the execution face). | ## Concurrency diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 3cde5d6a1a..8eae576f09 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -54,7 +54,7 @@ export interface Config { deny?: string[] } /** - * Maximum child depth: a non-negative safe integer (default `1`; `0` forbids + * Maximum child depth: a non-negative safe integer (default `3`; `0` forbids * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap * requires the provider's `depthLimit` capability (mount fails loud * otherwise), and a child AT the cap additionally loses this tool from its @@ -81,7 +81,7 @@ export const Config: z = z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), }).default(undefined as unknown as { allow: string[]; deny: string[] }), - maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(1), + maxDepth: z.union([z.natural().max(Number.MAX_SAFE_INTEGER), z.const('provider-managed' as const)]).default(3), }) /** diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index f827d6d2ba..7aa8f10d45 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -912,23 +912,24 @@ describe('depth budget defaults and schema hiding', () => { return { ctx, requests } } - it('defaults maxDepth to 1 and forwards it in the start request', async () => { + it('defaults maxDepth to 3 and forwards it in the start request', async () => { const { ctx, requests } = await captureSetup() await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(requests[0]?.maxDepth).toBe(1) + expect(requests[0]?.maxDepth).toBe(3) + expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent') }) it('denies its own toolName to a child at the depth cap', async () => { // The child of a depth-0 parent under maxDepth 1 sits AT the cap: any // delegation it attempted would be rejected, so the tool must not appear in // its schema at all (prompt-face hiding; the service still rejects). - const { ctx, requests } = await captureSetup() + const { ctx, requests } = await captureSetup({ maxDepth: 1 }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(requests[0]?.toolFilter?.deny).toContain('subagent') }) it('merges the cap denial into a configured tool filter', async () => { - const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] } }) + const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] }, maxDepth: 1 }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent'])) }) From 85fbf122a9e2f5872c2d4e1561cb1dbced9bf0a2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 17:22:50 +0800 Subject: [PATCH 087/132] docs: regenerate event-producer matrix after master merge --- docs/event-producer-consumer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ed028010d9..cd40c40e5c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:143`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:152`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:307`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | From 6eaa6073a25cbc1b798a49b5e0be9b6d196d86e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:32:17 +0800 Subject: [PATCH 088/132] test(code-runtime): stabilize slow-binding budget case --- .../code-runtime/code-runtime-worker/tests/runtime.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 23c9a8ee60..ae2cb2b639 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -129,10 +129,12 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { }, 15_000) it('does not charge time spent awaiting a slow binding against the compute budget', async () => { - const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 }) + // Keep the binding delay above the compute allowance while leaving enough + // headroom for worker bootstrap on loaded CI hosts. + const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 }) const result = await runtime.run({ program: 'return await tools.slow({})', - bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }), + bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }), }) expect(result.error).toBeUndefined() expect(result.value).toBe('slow-done') From 23330b7a7d00fa6f4f058423aea57d4552792808 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:35:01 +0800 Subject: [PATCH 089/132] chore(goal): remove retired e2e dependencies --- knip.json | 2 +- packages/goal/tool-goal/package.json | 1 - pnpm-lock.yaml | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/knip.json b/knip.json index 9b7b511c44..8fa18dc524 100644 --- a/knip.json +++ b/knip.json @@ -72,7 +72,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/tool-goal": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/code-runtime/code-runtime-worker": { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 28cd6839f8..b9b4912cf4 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 957e3d05dd..9f7a641df7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,9 +425,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 4e7a2c732f77834e2dc5d03e3861e4a0c18bd132 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:41:22 +0800 Subject: [PATCH 090/132] fix(commands): remove adapter surface filtering --- ...7-19-plugin-command-registration.i18n.yaml | 4 +- .../2026-07-19-plugin-command-registration.md | 11 ++-- ...26-07-19-plugin-command-registration.zh.md | 11 ++-- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 23 +++---- docs/core-data-structures/commands.md | 19 ++---- docs/event-producer-consumer.md | 2 +- docs/glossary.md | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 24 +++---- packages/ui/acp/src/index.ts | 4 +- packages/ui/acp/tests/commands.spec.ts | 5 +- packages/ui/commands/README.md | 4 +- packages/ui/commands/src/index.ts | 56 ++++------------- packages/ui/commands/tests/commands.spec.ts | 63 ++++++++----------- packages/ui/tui/src/index.ts | 13 +--- packages/ui/tui/tests/tui.spec.ts | 11 +--- scripts/type-equiv.manifest.json | 1 - website/zh-CN/api/harness/commands.md | 48 +++++++------- website/zh-CN/api/harness/events.md | 2 +- 19 files changed, 112 insertions(+), 192 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 48d880e666..7294638f1a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-plugin-command-registration.md: 821f22405fd6a4bc0b8bfd3c5edf773be844899d -2026-07-19-plugin-command-registration.zh.md: 7a2ed82eb1a8f55d4d701a68c4053e5d412b7d04 +2026-07-19-plugin-command-registration.md: c414f8183e100f712a552828992d108193fc33cf +2026-07-19-plugin-command-registration.zh.md: 3cd820c55ec30f3b2f6cf471376abfd8edd8ac5e diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index 821f22405f..c414f8183e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -16,9 +16,9 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ### Registry contract -A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, an optional non-empty surface list, and an abortable handler. Omitted surfaces resolve to `tui` plus `acp`. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. +A `CommandDefinition` contains a lowercase name without `/`, a non-empty description, an optional unstructured-input hint, and an abortable handler. Registration validates and detaches the metadata, freezes the effective definition, and returns the exact Cordis effect disposer. Duplicate names fail within one layer. Every adapter consuming the registry sees every effective definition; a command plugin that cannot operate in a deployment omits its registration there instead of encoding adapter identities in the shared domain. -`list(agent, surface)` returns immutable name-sorted descriptors after surface filtering and scoped shadowing. `find(agent, surface, name)` resolves the effective definition. `execute(agent, surface, line, signal)` parses and runs a visible definition, returning a detached `success` or `error` result; invalid syntax, unknown names, and hidden definitions return `undefined` so the adapter owns its direct error text. +`list(agent)` returns immutable name-sorted descriptors after scoped shadowing. `find(agent, name)` resolves the effective definition. `execute(agent, line, signal)` parses and runs a known definition, returning a detached `success` or `error` result; invalid syntax and unknown names return `undefined` so the adapter owns its direct error text. `parseCommand(line)` requires `/` at byte zero, a lowercase ASCII name containing letters, digits, `_`, or `-`, then whitespace or end-of-input. It preserves the complete adapter-delivered suffix as `rawInput`, including separator whitespace. Command-specific plugins own every further grammar decision. @@ -30,13 +30,13 @@ Registration and removal emit the unfiltered, non-vetoing `commands/change` regi ### Direct dispatch and cancellation -Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, surface, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. +Commands run in a human-only command plane. Their input does not become `user/message`, their output does not become a session event, and neither is sent to the model. A handler receives the exact target agent, raw input, and request-owned `AbortSignal`. The registry stops awaiting an uncooperative handler when the signal aborts; the handler remains responsible for stopping external side effects already started. Expected handler failures return `CommandResult.error`. Thrown or malformed results remain adapter-visible command failures, not model messages. This boundary deliberately separates UI output from durable domain mutation: a goal command may change `ctx.goals`, for example, but the goal service owns that persisted state. ### TUI mapping -The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live `tui` catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. +The TUI registers `help`, `clear`, `cancel`, `reasoning`, `tools`, `redraw`, and `exit` as agent-scoped command definitions instead of switching on strings. Its autocomplete and help view read the live catalog, so plugin commands appear and disappear with their effects. Any submitted line beginning with `/` stays in the command plane; unknown input produces a terminal warning rather than falling through to `Agent.send()` or `Agent.steer()`. Each submitted command owns an `AbortController`. TUI disposal aborts outstanding dispatches, removes the local definitions, and waits for the command-producing fiber before completing teardown. @@ -50,7 +50,7 @@ One model prompt or direct command may be in flight per ACP session, independent ## Testing -The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, default and explicit surfaces, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. +The registry suite covers syntax boundaries, immutable normalization, runtime metadata validation, deterministic sorting, global and scoped shadowing, duplicate rejection, exact disposal, contained change-notification failures, direct invocation, expected and malformed results, synchronous and asynchronous failure, and every abort timing edge at per-file 100% statement, branch, function, and line coverage. TUI tests exercise all migrated built-ins, live plugin discovery, help/autocomplete refresh, direct results, unknown-command rejection, raw-input delivery, definition removal, startup rollback, and disposal cancellation. ACP tests use the real SDK connection, agent factory, loop, and JSONL persistence to verify create/load snapshots, dynamic updates, scoped multi-session catalogs, supported-block flattening, direct success/error/failure, unknown-command isolation, cancellation, and the absence of model requests or session messages. The SDK helper suite pins direct-ACP composition. Keyless ACP and terminal snapshots pin the new protocol and rendered transcript shapes. @@ -60,6 +60,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. - **Put the registry in the core agent spine** — rejected because headless and JSON-RPC agents do not consume it, while the two UI app bundles can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. +- **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. - **Persist generic command input and output** — rejected because adapter notices are not model-visible state. A handler that changes durable behavior calls the owning domain API, which records its own events. - **Restrict ACP commands to one text block** — rejected because ACP v1 permits accompanying content; the bridge already has a lossless accepted-block translation. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index 7a2ed82eb1..3cd820c55e 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -16,9 +16,9 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 ### 注册表契约 -`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示、可选的非空界面列表,以及可取消处理器。省略界面时解析为 `tui` 与 `acp`。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。 +`CommandDefinition` 包含不带 `/` 的小写名称、非空描述、可选的非结构化输入提示,以及可取消处理器。注册会校验并分离元数据、冻结有效定义,并返回准确的 Cordis effect disposer(副作用释放器)。同一层中的重复名称会失败。每个消费该注册表的适配器都能看到所有有效定义;若命令插件无法在某种部署中运行,它就不在该部署中注册,而不是把适配器身份编码进共享领域。 -`list(agent, surface)` 在界面过滤与作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, surface, name)` 解析有效定义。`execute(agent, surface, line, signal)` 解析并运行可见定义,返回分离后的 `success` 或 `error` 结果;无效语法、未知名称和对该界面隐藏的定义返回 `undefined`,由适配器拥有直接错误文本。 +`list(agent)` 在作用域遮蔽后返回不可变、按名称排序的描述符。`find(agent, name)` 解析有效定义。`execute(agent, line, signal)` 解析并运行已知定义,返回分离后的 `success` 或 `error` 结果;无效语法和未知名称返回 `undefined`,由适配器拥有直接错误文本。 `parseCommand(line)` 要求 `/` 位于第零字节,后接由字母、数字、`_` 或 `-` 组成的小写 ASCII 名称,并以空白或输入末尾结束。它把适配器交付的完整后缀保留为 `rawInput`,包括分隔空白。每个命令插件自行拥有后续语法决策。 @@ -30,13 +30,13 @@ TUI 拥有七个斜杠命令,而 ACP 定义了标准命令目录与调用形 ### 直接分派与取消 -命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、界面、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 +命令在仅面向人类的命令平面中运行。输入不会成为 `user/message`,输出不会成为会话事件,两者都不会发送给模型。处理器接收准确的目标智能体、原始输入和请求拥有的 `AbortSignal`。信号中止时,注册表不再等待不合作的处理器;处理器仍负责停止已经启动的外部副作用。 预期的处理器失败返回 `CommandResult.error`。抛出的异常或格式错误的结果仍是适配器可见的命令失败,而不是模型消息。该边界有意分离 UI 输出与持久领域变更:例如目标命令可以改变 `ctx.goals`,但持久状态由目标服务拥有。 ### TUI 映射 -TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时 `tui` 目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 +TUI 把 `help`、`clear`、`cancel`、`reasoning`、`tools`、`redraw` 和 `exit` 注册为智能体作用域命令定义,不再对字符串执行 switch。自动补全与帮助视图读取实时目录,因此插件命令会随其副作用出现和消失。任何以 `/` 开头的提交行都留在命令平面;未知输入产生终端警告,不会落入 `Agent.send()` 或 `Agent.steer()`。 每个提交的命令拥有一个 `AbortController`。TUI 释放会中止未完成的分派、移除本地定义,并等待命令生产者 fiber(纤程)后再完成清理。 @@ -50,7 +50,7 @@ ACP 允许命令提示携带额外的受支持内容块。桥接应用普通的 ## 测试 -注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、默认和显式界面、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 +注册表测试覆盖语法边界、不可变规范化、运行时元数据校验、确定性排序、全局与作用域遮蔽、重复拒绝、准确释放、变更通知失败隔离、直接调用、预期和格式错误结果、同步与异步失败,以及每种中止时序边沿;该源文件达到逐文件 100% 语句、分支、函数和行覆盖率。 TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与自动补全刷新、直接结果、未知命令拒绝、原始输入交付、定义移除、启动回滚和释放取消。ACP 测试使用真实 SDK 连接、智能体工厂、循环与 JSONL 持久化,验证创建/加载快照、动态更新、作用域多会话目录、受支持块扁平化、直接成功/错误/失败、未知命令隔离、取消,以及不存在模型请求或会话消息。SDK helper 测试固定直接 ACP 组合。无密钥 ACP 与终端快照固定新的协议和渲染记录形态。 @@ -60,6 +60,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 - **把注册表放入核心智能体主干**——不予采纳,因为无头和 JSON-RPC 智能体不消费它,而两个 UI 应用组合包可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 +- **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 - **持久化通用命令输入与输出**——不予采纳,因为适配器提示不是模型可见状态。改变持久行为的处理器会调用拥有该状态的领域 API,由后者记录自己的事件。 - **把 ACP 命令限制为单个文本块**——不予采纳,因为 ACP v1 允许附带内容,而桥接已有无损的已接纳块转换。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f05d78e9eb..e0f2e924f0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -426,7 +426,7 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/ui/commands/src/index.ts:94`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts) ## `fs/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cac512b59d..85cadd1de1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -347,42 +347,39 @@ Human-command registry. Plain-context definitions are global; definitions regist ```ts cordis-catalog /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ -list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +list(agent: Agent): readonly CommandDescriptor[] /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ -find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +find(agent: Agent, name: string): CommandDefinition | undefined /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ -async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) · [CommandSurface](../core-data-structures/commands.md) +Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md) -Source: [`packages/ui/commands/src/index.ts:235`](../../packages/ui/commands/src/index.ts) +Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) diff --git a/docs/core-data-structures/commands.md b/docs/core-data-structures/commands.md index e8fd7ec497..c33b27ce1c 100644 --- a/docs/core-data-structures/commands.md +++ b/docs/core-data-structures/commands.md @@ -4,14 +4,9 @@ The human-command seam of [`dsh-commands`](../../packages/ui/commands). TUI and Source: [`packages/ui/commands/src/index.ts`](../../packages/ui/commands/src/index.ts) -## Surface and input metadata +## Input metadata -A definition selects one or more adapter identities. The shipped identities are `tui` and `acp`; the string intersection keeps the registry extensible without widening editor autocomplete to plain `string`. ACP currently exposes one unstructured-input hint. - -```ts type-equiv -/** A UI adapter capable of listing and executing human commands. */ -type CommandSurface = 'tui' | 'acp' | (string & {}) -``` +ACP currently exposes one unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable command input metadata compatible with ACP unstructured input. */ @@ -23,7 +18,7 @@ interface CommandInputDescriptor { ## Definition -`CommandDefinition` is the plugin-authored registration. Omitted surfaces resolve to both shipped adapters; the registry validates and freezes a detached effective definition. +`CommandDefinition` is the plugin-authored registration. The registry validates and freezes a detached effective definition. ```ts type-equiv /** Plugin-owned command registration. */ @@ -34,8 +29,6 @@ interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces exposing this command; omission means both shipped surfaces. */ - readonly surfaces?: readonly CommandSurface[] /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -50,8 +43,6 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi interface CommandInvocation { /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent - /** UI adapter that dispatched the command. */ - readonly surface: CommandSurface /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** Cancellation signal owned by the dispatching UI request. */ @@ -68,7 +59,7 @@ type CommandResult = ## Discovery and parsing views -Adapters receive handler-free immutable descriptors after scope resolution and surface filtering. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. +Adapters receive handler-free immutable descriptors after scope resolution. `parseCommand()` returns `ParsedCommand` before registry resolution; syntax-valid input can still name an unavailable command. ```ts type-equiv /** Handler-free immutable command view returned to UI adapters. */ @@ -79,8 +70,6 @@ interface CommandDescriptor { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces on which this definition is visible. */ - readonly surfaces: readonly CommandSurface[] } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9acbbbb348..0e02626b85 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:94`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/glossary.md b/docs/glossary.md index ffb900d7b5..19daaa29dd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -26,7 +26,6 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **human command** — a slash-prefixed instruction interpreted and executed by a human-facing adapter through `ctx.commands`, without becoming a model message. It is distinct from a model-facing tool and from shell command execution through `ctx.bash`. - **command plane** — discovery, parsing, dispatch, cancellation, and result rendering owned by UI adapters and command plugins. Command output is UI state unless the handler separately mutates a durable domain. -- **command surface** — the adapter identity used to filter definitions, such as `tui` or `acp`; one scoped definition may shadow a same-named global command for its exact agent. ## loop hierarchy diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8047af2834..b3701290d6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -204,19 +204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register(definition: CommandDefinition): () => void', - jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata, surface mask, and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', + jsDoc: '/**\n * Register a global or calling-agent-scoped command.\n * @param definition - discovery metadata and direct UI handler.\n * @returns the exact effect disposer that unregisters this definition.\n */', }, { - signature: 'list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[]', - jsDoc: '/**\n * List the effective immutable command descriptors for one agent and surface.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter requesting discovery metadata.\n * @returns name-sorted descriptors after scoped shadowing and surface filtering.\n */', + signature: 'list(agent: Agent): readonly CommandDescriptor[]', + jsDoc: '/**\n * List the effective immutable command descriptors for one agent.\n * @param agent - exact receiving agent and scoped-layer key.\n * @returns name-sorted descriptors after scoped shadowing.\n */', }, { - signature: 'find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined', - jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param surface - UI adapter performing the lookup.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition when visible on the surface.\n */', + signature: 'find(agent: Agent, name: string): CommandDefinition | undefined', + jsDoc: '/**\n * Resolve one effective command definition.\n * @param agent - exact receiving agent and scoped-layer key.\n * @param name - command name without a slash.\n * @returns the scoped shadow or global definition.\n */', }, { - signature: 'async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param surface - dispatching UI adapter.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax/name/surface does not resolve.\n */', + signature: 'async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Parse and execute a known command without sending it to the model.\n * @param agent - exact receiving agent.\n * @param line - complete slash-command line.\n * @param signal - cancellation signal owned by the UI request.\n * @returns a detached result, or `undefined` when syntax or name does not resolve.\n */', }, ], }, @@ -1127,11 +1127,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandDefinition', - declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces?: readonly CommandSurface[];\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', }, { name: 'CommandDescriptor', - declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly surfaces: readonly CommandSurface[];\n}', + declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}', }, { name: 'CommandInputDescriptor', @@ -1139,16 +1139,12 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly surface: CommandSurface;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, - { - name: 'CommandSurface', - declaration: 'export type CommandSurface = \'tui\' | \'acp\' | (string & {});', - }, { name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 459dc50a43..2f075a7ccc 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -514,7 +514,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /** Project the effective registry view onto ACP discovery metadata. */ - const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent, 'acp').map(command => ({ + const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({ name: command.name, description: command.description, ...command.input === undefined ? {} : { input: { hint: command.input.hint } }, @@ -905,7 +905,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const controller = new AbortController() rec.commandAbort = controller try { - const result = await commands.execute(rec.agent, 'acp', commandLine, controller.signal) + const result = await commands.execute(rec.agent, commandLine, controller.signal) if (result !== undefined && result.text !== undefined && result.text !== '') { notify({ sessionId: rec.agent.session.id, diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts index 946ee67950..71aae1ea64 100644 --- a/packages/ui/acp/tests/commands.spec.ts +++ b/packages/ui/acp/tests/commands.spec.ts @@ -55,7 +55,6 @@ describe('ACP plugin commands', () => { const dispose = harness.ctx.commands.register({ name: 'alpha', description: 'Alpha command', - surfaces: ['acp'], handler: () => ({ kind: 'success' }), }) await vi.waitFor(() => { @@ -126,7 +125,7 @@ describe('ACP plugin commands', () => { }) expect(response.stopReason).toBe('end_turn') - expect(seen).toHaveBeenCalledWith(expect.objectContaining({ surface: 'acp', rawInput: ' raw args ' })) + expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' })) expect(messageText(harness, sessionId)).toContain('DIRECT RESULT') const updatesAfterText = harness.sessionUpdates.length await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] }) @@ -260,7 +259,7 @@ describe('ACP plugin commands', () => { if (agentA === undefined) throw new Error('session A has no agent') await agentA.ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ - name: 'private', description: 'Only session A', surfaces: ['acp'], + name: 'private', description: 'Only session A', handler: () => ({ kind: 'success', text: 'A ONLY' }), }) }) diff --git a/packages/ui/commands/README.md b/packages/ui/commands/README.md index 5b6c8b9425..6e0efa458b 100644 --- a/packages/ui/commands/README.md +++ b/packages/ui/commands/README.md @@ -4,9 +4,9 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu ## Service contract -`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, optional surface list, and abortable handler. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. -`list(agent, surface)` returns immutable, name-sorted descriptors after scoped shadowing and surface filtering. `find(agent, surface, name)` returns the corresponding definition. `execute(agent, surface, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax, unknown names, or commands hidden from that surface. +`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names. `parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 529dea11b4..16e665e71f 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -11,11 +11,6 @@ import type { ScopeKey } from '@deepseek-ai/dsh-scope' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -const SURFACE_NAME = /^[a-z][a-z0-9-]*$/u -const DEFAULT_SURFACES = ['tui', 'acp'] as const - -/** A UI adapter capable of listing and executing human commands. */ -export type CommandSurface = 'tui' | 'acp' | (string & {}) /** Immutable command input metadata compatible with ACP unstructured input. */ export interface CommandInputDescriptor { @@ -27,8 +22,6 @@ export interface CommandInputDescriptor { export interface CommandInvocation { /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent - /** UI adapter that dispatched the command. */ - readonly surface: CommandSurface /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** Cancellation signal owned by the dispatching UI request. */ @@ -48,8 +41,6 @@ export interface CommandDefinition { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces exposing this command; omission means both shipped surfaces. */ - readonly surfaces?: readonly CommandSurface[] /** Execute against the receiving agent without sending the command to the model. */ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise } @@ -62,8 +53,6 @@ export interface CommandDescriptor { readonly description: string /** Optional free-form input hint advertised to capable clients. */ readonly input?: CommandInputDescriptor - /** Surfaces on which this definition is visible. */ - readonly surfaces: readonly CommandSurface[] } /** Syntactically valid slash command before registry resolution. */ @@ -75,7 +64,7 @@ export interface ParsedCommand { } interface RegisteredCommand { - readonly definition: CommandDefinition & { readonly surfaces: readonly CommandSurface[] } + readonly definition: CommandDefinition readonly descriptor: CommandDescriptor } @@ -175,33 +164,16 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { } input = Object.freeze({ hint: rawInput.hint }) } - const surfaces = [...(definition.surfaces ?? DEFAULT_SURFACES)] - if (surfaces.length === 0) { - throw new TypeError(`command "${definition.name}" must expose at least one surface`) - } - const unique = new Set() - for (const surface of surfaces) { - if (!SURFACE_NAME.test(surface)) { - throw new TypeError(`command "${definition.name}" surface "${surface}" must match ${String(SURFACE_NAME)}`) - } - if (unique.has(surface)) { - throw new TypeError(`command "${definition.name}" surface "${surface}" is duplicated`) - } - unique.add(surface) - } - const frozenSurfaces = Object.freeze(surfaces) const normalized = Object.freeze({ name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, - surfaces: frozenSurfaces, handler: definition.handler, }) const descriptor = Object.freeze({ name: normalized.name, description: normalized.description, ...normalized.input === undefined ? {} : { input: normalized.input }, - surfaces: normalized.surfaces, }) return { definition: normalized, descriptor } } @@ -242,7 +214,7 @@ export class CommandService extends Service { /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void { @@ -268,14 +240,12 @@ export class CommandService extends Service { } /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ - list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] { + list(agent: Agent): readonly CommandDescriptor[] { return Object.freeze([...this.view(agent).values()] - .filter(command => command.definition.surfaces.includes(surface)) .map(command => command.descriptor) // Names are unique in the effective view, so equality is impossible. .sort((left, right) => left.name < right.name ? -1 : 1)) @@ -284,35 +254,31 @@ export class CommandService extends Service { /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ - find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined { - const command = this.view(agent).get(name) - return command?.definition.surfaces.includes(surface) === true ? command.definition : undefined + find(agent: Agent, name: string): CommandDefinition | undefined { + return this.view(agent).get(name)?.definition } /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ async execute( agent: Agent, - surface: CommandSurface, line: string, signal: AbortSignal, ): Promise { const parsed = parseCommand(line) if (parsed === undefined) return undefined const command = this.view(agent).get(parsed.name) - if (command === undefined || !command.definition.surfaces.includes(surface)) return undefined + if (command === undefined) return undefined if (signal.aborted) throw abortError(signal) - const invocation = Object.freeze({ agent, surface, rawInput: parsed.rawInput, signal }) + const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) const output = command.definition.handler(invocation) return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal)) } diff --git a/packages/ui/commands/tests/commands.spec.ts b/packages/ui/commands/tests/commands.spec.ts index e3da186492..839ccb0297 100644 --- a/packages/ui/commands/tests/commands.spec.ts +++ b/packages/ui/commands/tests/commands.spec.ts @@ -44,7 +44,7 @@ describe('parseCommand()', () => { }) describe('CommandService', () => { - it('lists immutable global descriptors with default surfaces and ACP input metadata', async () => { + it('lists immutable global descriptors with input metadata', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const definition: CommandDefinition = { @@ -55,20 +55,17 @@ describe('CommandService', () => { } ctx.commands.register(definition) - const listed = ctx.commands.list(agent, 'acp') + const listed = ctx.commands.list(agent) expect(listed).toEqual([{ name: 'inspect', description: 'Inspect state', input: { hint: '' }, - surfaces: ['tui', 'acp'], }]) expect(Object.isFrozen(listed)).toBe(true) expect(Object.isFrozen(listed[0])).toBe(true) expect(Object.isFrozen(listed[0]?.input)).toBe(true) - expect(Object.isFrozen(listed[0]?.surfaces)).toBe(true) - expect(ctx.commands.find(agent, 'tui', 'inspect')).toMatchObject({ name: 'inspect' }) - expect(ctx.commands.find(agent, 'other', 'inspect')).toBeUndefined() - expect(ctx.commands.find(agent, 'tui', 'missing')).toBeUndefined() + expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'missing')).toBeUndefined() }) it('sorts distinct effective command names', async () => { @@ -77,7 +74,7 @@ describe('CommandService', () => { ctx.commands.register(command('zeta')) ctx.commands.register(command('alpha')) ctx.commands.register(command('middle')) - expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta']) }) it('uses agent-scoped shadows and removes them with their scope', async () => { @@ -85,17 +82,16 @@ describe('CommandService', () => { const { scope, agent } = await mintAgentScope(ctx, 'a') const other = { id: 'other' as SessionId } as Agent ctx.commands.register(command('shared', 'global')) - scope.ctx.commands.register({ ...command('shared', 'scoped'), surfaces: ['tui'] }) + scope.ctx.commands.register(command('shared', 'scoped')) - expect(ctx.commands.list(agent, 'tui').map(item => item.name)).toEqual(['shared']) - expect(ctx.commands.list(agent, 'acp')).toEqual([]) - expect(ctx.commands.find(agent, 'tui', 'shared')?.handler).toBeDefined() - expect(ctx.commands.list(other, 'acp').map(item => item.name)).toEqual(['shared']) - expect(await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal)) + expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) + expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) + expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal)) .toEqual({ kind: 'success', text: 'scoped' }) await scope.dispose() - expect((await ctx.commands.execute(agent, 'tui', '/shared', new AbortController().signal))?.text).toBe('global') + expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global') }) it('rejects duplicates within one layer while allowing a scoped shadow', async () => { @@ -124,14 +120,14 @@ describe('CommandService', () => { ctx.on('commands/change', afterFailures) const removeContained = ctx.commands.register(command('contained')) const { agent } = await mintAgentScope(ctx, 'a') - expect(ctx.commands.find(agent, 'tui', 'contained')).toBeDefined() + expect(ctx.commands.find(agent, 'contained')).toBeDefined() expect(afterFailures).toHaveBeenCalledTimes(1) await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw') expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected') }) removeContained() - expect(ctx.commands.find(agent, 'tui', 'contained')).toBeUndefined() + expect(ctx.commands.find(agent, 'contained')).toBeUndefined() expect(afterFailures).toHaveBeenCalledTimes(2) }) @@ -155,22 +151,20 @@ describe('CommandService', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' })) - ctx.commands.register({ name: 'run', description: 'Run it', surfaces: ['acp'], handler: seen }) + ctx.commands.register({ name: 'run', description: 'Run it', handler: seen }) const controller = new AbortController() - const result = await ctx.commands.execute(agent, 'acp', '/run untouched ', controller.signal) + const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal) expect(result).toEqual({ kind: 'success', text: 'ok' }) expect(Object.isFrozen(result)).toBe(true) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ agent, - surface: 'acp', rawInput: ' untouched ', signal: controller.signal, })) - await expect(ctx.commands.execute(agent, 'tui', '/run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, 'acp', 'run', controller.signal)).resolves.toBeUndefined() - await expect(ctx.commands.execute(agent, 'acp', '/missing', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined() + await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined() }) it('stops awaiting an aborted handler and handles an already-aborted signal', async () => { @@ -183,18 +177,18 @@ describe('CommandService', () => { handler: () => new Promise((resolve) => { release = resolve }), }) const running = new AbortController() - const promise = ctx.commands.execute(agent, 'tui', '/wait', running.signal) + const promise = ctx.commands.execute(agent, '/wait', running.signal) running.abort('operator cancelled command') await expect(promise).rejects.toThrow('operator cancelled command') release({ kind: 'success', text: 'late' }) const already = new AbortController() already.abort(new Error('already gone')) - await expect(ctx.commands.execute(agent, 'tui', '/wait', already.signal)).rejects.toThrow('already gone') + await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone') const defaultReason = new AbortController() defaultReason.abort({ source: 'test' }) - await expect(ctx.commands.execute(agent, 'tui', '/wait', defaultReason.signal)).rejects.toThrow('command aborted') + await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted') }) it('propagates an asynchronously rejected handler', async () => { @@ -205,7 +199,7 @@ describe('CommandService', () => { description: 'Reject', handler: () => Promise.reject(new Error('handler rejected')), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal)) .rejects.toThrow('handler rejected') ctx.commands.register({ @@ -214,7 +208,7 @@ describe('CommandService', () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization handler: () => Promise.reject('not an Error'), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject-value', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal)) .rejects.toThrow('command handler rejected with a non-Error value: not an Error') const hostile = { toString(): string { throw new Error('cannot render') } } @@ -224,7 +218,7 @@ describe('CommandService', () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization handler: () => Promise.reject(hostile), }) - await expect(ctx.commands.execute(agent, 'tui', '/reject-hostile', new AbortController().signal)) + await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal)) .rejects.toMatchObject({ message: 'command handler rejected with a non-Error value: ', cause: hostile, @@ -243,7 +237,7 @@ describe('CommandService', () => { return { kind: 'success' } }, }) - await expect(ctx.commands.execute(agent, 'tui', '/self-abort', controller.signal)) + await expect(ctx.commands.execute(agent, '/self-abort', controller.signal)) .rejects.toThrow('aborted in handler') }) @@ -255,7 +249,7 @@ describe('CommandService', () => { description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }), }) - const result = await ctx.commands.execute(agent, 'tui', '/denied', new AbortController().signal) + const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal) expect(result).toEqual({ kind: 'error', text: 'not now' }) expect(Object.isFrozen(result)).toBe(true) @@ -264,7 +258,7 @@ describe('CommandService', () => { description: 'No output', handler: () => ({ kind: 'success' }), }) - const silent = await ctx.commands.execute(agent, 'tui', '/silent', new AbortController().signal) + const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal) expect(silent).toEqual({ kind: 'success' }) expect(Object.isFrozen(silent)).toBe(true) }) @@ -273,9 +267,6 @@ describe('CommandService', () => { [{ ...command('Bad') }, /command name/], [{ ...command('empty-description'), description: ' ' }, /description/], [{ ...command('empty-hint'), input: { hint: '' } }, /input hint/], - [{ ...command('no-surface'), surfaces: [] }, /at least one surface/], - [{ ...command('bad-surface'), surfaces: ['ACP'] }, /surface/], - [{ ...command('duplicate-surface'), surfaces: ['tui', 'tui'] }, /duplicated/], [{ ...command('bad-handler'), handler: undefined }, /handler/], ] as const)('rejects invalid definition %#', async (definition, expected) => { const ctx = await mount() @@ -298,6 +289,6 @@ describe('CommandService', () => { description: 'Broken', handler: () => output as never, }) - await expect(ctx.commands.execute(agent, 'tui', '/broken', new AbortController().signal)).rejects.toThrow(expected) + await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected) }) }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2fe7398931..83432b91d4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1144,7 +1144,7 @@ export function createTuiChat( } const showHelp = (): void => { - const commandLines = ctx.commands.list(agent, 'tui').map((command) => { + const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` return `/${command.name}${input} — ${command.description}` }) @@ -1162,7 +1162,7 @@ export function createTuiChat( const refreshCommandAutocomplete = (): void => { editor.setAutocompleteProvider(new CombinedAutocompleteProvider( - ctx.commands.list(agent, 'tui').map(command => ({ + ctx.commands.list(agent).map(command => ({ name: command.name, description: command.description, })), @@ -1179,19 +1179,16 @@ export function createTuiChat( commandCtx.commands.register({ name: 'help', description: 'Show keyboard shortcuts and commands', - surfaces: ['tui'], handler: () => { showHelp(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'clear', description: 'Clear the transcript view (session history is unchanged)', - surfaces: ['tui'], handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'cancel', description: 'Cancel the active turn', - surfaces: ['tui'], handler: () => { if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } agent.cancel('cancelled from terminal') @@ -1201,25 +1198,21 @@ export function createTuiChat( commandCtx.commands.register({ name: 'reasoning', description: 'Toggle reasoning blocks', - surfaces: ['tui'], handler: () => { toggleReasoning(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'tools', description: 'Expand or collapse all tool cards', - surfaces: ['tui'], handler: () => { toggleTools(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'redraw', description: 'Invalidate components and redraw the terminal', - surfaces: ['tui'], handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'exit', description: 'Exit after the active turn reaches idle', - surfaces: ['tui'], handler: () => { requestExit(); return { kind: 'success' } }, }) }) @@ -1227,7 +1220,7 @@ export function createTuiChat( const runCommand = (text: string): void => { const controller = new AbortController() commandControllers.add(controller) - void ctx.commands.execute(agent, 'tui', text, controller.signal).then( + void ctx.commands.execute(agent, text, controller.signal).then( (result) => { if (disposed) return if (result === undefined) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index be67e117cd..50280d61b9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -442,13 +442,11 @@ describe('pi-tui chat lifecycle and transcript', () => { name: 'plugin-check', description: 'Run a plugin command', input: { hint: '' }, - surfaces: ['tui'], handler, }) result.ctx.commands.register({ name: 'plugin-fail', description: 'Fail a plugin command', - surfaces: ['tui'], handler: () => { throw new Error('plugin command exploded') }, }) @@ -459,7 +457,6 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(handler).toHaveBeenCalledTimes(1) const invocation = handler.mock.calls[0]?.[0] expect(invocation?.agent).toBe(result.agent) - expect(invocation?.surface).toBe('tui') // pi-tui's Editor owns terminal-line normalization and removes trailing // spaces before onSubmit; the registry preserves the adapter-delivered line. expect(invocation?.rawInput).toBe(' value') @@ -472,10 +469,10 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('/plugin-check — Run a plugin command') - expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toContain('help') + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help') await result.controller.dispose() - expect(result.ctx.commands.list(result.agent, 'tui').map(command => command.name)).toEqual([ + expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([ 'plugin-check', 'plugin-fail', ]) @@ -490,7 +487,6 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.commands.register({ name: 'wait-plugin', description: 'Wait until disposal', - surfaces: ['tui'], handler: ({ signal }) => { commandSignal = signal started() @@ -518,7 +514,6 @@ describe('pi-tui chat lifecycle and transcript', () => { result.ctx.commands.register({ name: 'late-success', description: 'Resolve while the TUI closes', - surfaces: ['tui'], handler: () => new Promise((resolve) => { resolveCommand = resolve started() @@ -1027,7 +1022,7 @@ describe('terminal mounting', () => { expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() })) .toThrow('terminal startup failed') await tick() - expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!, 'tui')).toEqual([]) + expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([]) expect(terminal.stopped).toBe(1) expect(terminal.progress).toEqual([false, true, false]) await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 02755f19ae..83005c9bb8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,7 +39,6 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", "source": "packages/goal/goal/src/types.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", "source": "packages/goal/goal/src/types.ts" }, - { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandSurface", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInputDescriptor", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandDefinition", "source": "packages/ui/commands/src/index.ts" }, { "doc": "docs/core-data-structures/commands.md", "symbol": "CommandInvocation", "source": "packages/ui/commands/src/index.ts" }, diff --git a/website/zh-CN/api/harness/commands.md b/website/zh-CN/api/harness/commands.md index e59a2203da..3b92682dac 100644 --- a/website/zh-CN/api/harness/commands.md +++ b/website/zh-CN/api/harness/commands.md @@ -6,14 +6,14 @@ Human-command registry. Plain-context definitions are global; definitions registered through a command-injected child of an agent context shadow globals for that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L235) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L207) ### ctx.commands.register(definition) ```ts website-api /** * Register a global or calling-agent-scoped command. - * @param definition - discovery metadata, surface mask, and direct UI handler. + * @param definition - discovery metadata and direct UI handler. * @returns the exact effect disposer that unregisters this definition. */ register(definition: CommandDefinition): () => void @@ -21,77 +21,71 @@ register(definition: CommandDefinition): () => void Register a global or calling-agent-scoped command. -- `definition` — discovery metadata, surface mask, and direct UI handler. +- `definition` — discovery metadata and direct UI handler. **Returns** the exact effect disposer that unregisters this definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L248) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L220) -### ctx.commands.list(agent, surface) +### ctx.commands.list(agent) ```ts website-api /** - * List the effective immutable command descriptors for one agent and surface. + * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter requesting discovery metadata. - * @returns name-sorted descriptors after scoped shadowing and surface filtering. + * @returns name-sorted descriptors after scoped shadowing. */ -list(agent: Agent, surface: CommandSurface): readonly CommandDescriptor[] +list(agent: Agent): readonly CommandDescriptor[] ``` -List the effective immutable command descriptors for one agent and surface. +List the effective immutable command descriptors for one agent. - `agent` — exact receiving agent and scoped-layer key. -- `surface` — UI adapter requesting discovery metadata. -**Returns** name-sorted descriptors after scoped shadowing and surface filtering. +**Returns** name-sorted descriptors after scoped shadowing. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L276) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L247) -### ctx.commands.find(agent, surface, name) +### ctx.commands.find(agent, name) ```ts website-api /** * Resolve one effective command definition. * @param agent - exact receiving agent and scoped-layer key. - * @param surface - UI adapter performing the lookup. * @param name - command name without a slash. - * @returns the scoped shadow or global definition when visible on the surface. + * @returns the scoped shadow or global definition. */ -find(agent: Agent, surface: CommandSurface, name: string): CommandDefinition | undefined +find(agent: Agent, name: string): CommandDefinition | undefined ``` Resolve one effective command definition. - `agent` — exact receiving agent and scoped-layer key. -- `surface` — UI adapter performing the lookup. - `name` — command name without a slash. -**Returns** the scoped shadow or global definition when visible on the surface. +**Returns** the scoped shadow or global definition. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L291) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L260) -### ctx.commands.execute(agent, surface, line, signal) +### ctx.commands.execute(agent, line, signal) ```ts website-api /** * Parse and execute a known command without sending it to the model. * @param agent - exact receiving agent. - * @param surface - dispatching UI adapter. * @param line - complete slash-command line. * @param signal - cancellation signal owned by the UI request. - * @returns a detached result, or `undefined` when syntax/name/surface does not resolve. + * @returns a detached result, or `undefined` when syntax or name does not resolve. */ -async execute( agent: Agent, surface: CommandSurface, line: string, signal: AbortSignal, ): Promise +async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise ``` Parse and execute a known command without sending it to the model. - `agent` — exact receiving agent. -- `surface` — dispatching UI adapter. - `line` — complete slash-command line. - `signal` — cancellation signal owned by the UI request. -**Returns** a detached result, or `undefined` when syntax/name/surface does not resolve. +**Returns** a detached result, or `undefined` when syntax or name does not resolve. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L304) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L271) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 18f202f773..e9266114b1 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -490,7 +490,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request A command was registered or unregistered. This is an unfiltered registry notification because a global or scoped change may affect any UI view. Observer failures are contained and cannot veto the registry mutation. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L94) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/commands/src/index.ts#L83) ## fs/* From 4e747942360e9800b3c6a2f570bdd76fde0814f0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:44:57 +0800 Subject: [PATCH 091/132] test(goal): isolate the ACP lifecycle snapshot --- .../goal-session/input.json | 0 .../goal-session/replay.override.json | 0 .../goal-session/session.expected.jsonl | 0 .../goal-session/session.jsonl | 0 .../goal-session/stdout.expected.jsonl | 0 examples/acp-agent/tests/goal.snapshot.ts | 4 +++- knip.json | 2 +- packages/goal/goal-session/package.json | 1 - pnpm-lock.yaml | 9 +++------ 9 files changed, 7 insertions(+), 9 deletions(-) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/input.json (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/replay.override.json (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/session.expected.jsonl (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/session.jsonl (100%) rename examples/acp-agent/tests/{snapshots => goal-snapshots}/goal-session/stdout.expected.jsonl (100%) diff --git a/examples/acp-agent/tests/snapshots/goal-session/input.json b/examples/acp-agent/tests/goal-snapshots/goal-session/input.json similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/input.json rename to examples/acp-agent/tests/goal-snapshots/goal-session/input.json diff --git a/examples/acp-agent/tests/snapshots/goal-session/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/replay.override.json rename to examples/acp-agent/tests/goal-snapshots/goal-session/replay.override.json diff --git a/examples/acp-agent/tests/snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/session.expected.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/session.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl diff --git a/examples/acp-agent/tests/snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/goal-session/stdout.expected.jsonl rename to examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 440eb66de6..42e3041721 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -14,7 +14,9 @@ import { foldGoal } from '@deepseek-ai/dsh-goal' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { describe, expect, it } from 'vitest' -const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/goal-session') +// This lifecycle proof has goal-specific timestamp normalization and semantic +// assertions, so it owns a separate snapshot root from the generic ACP suite. +const scenarioDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-session') const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') diff --git a/knip.json b/knip.json index bc531313dc..8ba86243ca 100644 --- a/knip.json +++ b/knip.json @@ -72,7 +72,7 @@ "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/goal-session": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/goal/tool-goal": { diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index c1493d017c..48a77019a3 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -34,7 +34,6 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 907dd906e0..4e2acb48e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1006,9 +1009,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1036,9 +1036,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From c40e63d04b68b8cfece277019f2062ebcdc2e953 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:51:21 +0800 Subject: [PATCH 092/132] fix(session-persistence-jsonl): require delegation depth --- docs/core-data-structures/persistence.md | 2 +- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 6 ++-- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/format.ts | 10 ++++-- .../tests/jsonl.spec.ts | 35 +++++++++++++++---- .../session-persistence/README.md | 4 +-- 7 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 2294f7b3db..08399bba90 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -71,7 +71,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ed3284f519..b0549d91ba 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 763acf3295..9b2eb74a37 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -562,9 +562,9 @@ export class SessionStore extends Service { * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` * populates the session with a copy of those events (replay/fork); - * `options.meta` attaches creation metadata (validated absolute `cwd`, - * `parentSession` lineage) as the immutable {@link SessionHeader} (the store - * fills `version`/`id`/`createdAt`). + * `options.meta` attaches creation metadata (validated absolute `cwd`, seed + * and parent lineage, and delegation depth) as the immutable + * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the * loop's final flush is captured before the store attachment ends), do NOT use this diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index cf733b85d4..d6130d414a 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -10,7 +10,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence .jsonl # header line + one SessionEvent per line (verbatim) ``` -- The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength? }`; every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). +- The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`). - Session ids are unvalidated branded strings, so they are percent-encoded to a single safe path segment before use (no traversal, no collision). ## Config diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 3fe3b2485c..3349eac12e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -25,7 +25,7 @@ export interface HeaderLine { cwd?: string parentSession?: SessionId seedLength?: number - delegationDepth?: number + delegationDepth: number } /** @@ -42,7 +42,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, - ...header.delegationDepth !== undefined ? { delegationDepth: header.delegationDepth } : {}, + delegationDepth: header.delegationDepth ?? 0, } } @@ -59,7 +59,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { ...line.cwd !== undefined ? { cwd: line.cwd } : {}, ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, - ...line.delegationDepth !== undefined ? { delegationDepth: line.delegationDepth } : {}, + delegationDepth: line.delegationDepth, } } @@ -71,6 +71,10 @@ function isHeaderLine(value: unknown): value is HeaderLine { && typeof (value as { version?: unknown }).version === 'number' && typeof (value as { id?: unknown }).id === 'string' && typeof (value as { createdAt?: unknown }).createdAt === 'number' + && typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number' + && Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth) + && (value as { delegationDepth: number }).delegationDepth >= 0 + && !Object.is((value as { delegationDepth: number }).delegationDepth, -0) ) } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index e7dc469132..9a236c5397 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -461,9 +461,30 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/) }) + it.each([ + ['missing', undefined], + ['a string', '1'], + ['fractional', 1.5], + ['negative', -1], + ])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => { + const log = JSON.stringify({ + type: 'session', + version: 0, + id: 'invalid-depth', + createdAt: 1, + ...delegationDepth === undefined ? {} : { delegationDepth }, + }) + '\n' + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + + it('rejects a session header with negative-zero delegationDepth', () => { + const log = '{"type":"session","version":0,"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n' + expect(() => scanLog(Buffer.from(log))).toThrow(/session header/) + }) + it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => { const log = [ - JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 ].join('\n') + '\n' @@ -475,7 +496,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1 JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }), @@ -487,7 +508,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => { const log = [ - JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }), '{not json', // corrupt, sits in the committed region (a turn/end follows) JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' @@ -495,7 +516,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { }) it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => { - const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1 }) + '\n' + const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1, delegationDepth: 0 }) + '\n' const scanned = scanLog(Buffer.from(log)) expect(scanned.events).toEqual([]) // committedBytes falls back to the header line's end (no preserved events). @@ -504,7 +525,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('a corrupt line after the last turn/end bounds the preserved tail', () => { const log = [ - JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), '{not json', // corrupt crash fragment, no turn/end committed ].join('\n') + '\n' @@ -515,7 +536,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { const log = [ - JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1 }), + JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }), JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }), JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }), JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail @@ -593,7 +614,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { // `readFirstLine` accumulates chunks before `list()` parses it. const bucket = join(root, '_no-cwd') await mkdir(bucket, { recursive: true }) - const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) + const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) }) await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n') const ids = (await ctx.sessionPersistence.list()).map(x => x.id) expect(ids).toContain('big') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index ca04cd1ab8..6d9e1392fe 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -2,7 +2,7 @@ The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface. -The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. +The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. ## Service API (`ctx.sessionPersistence`) @@ -51,7 +51,7 @@ Three backends run these suites: an in-memory reference (in `tests/`), `dsh-sess ## Metadata and location types -Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. +Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn. ## Model Experience From 78560dbc917ce906ca631f56a44eafbf2c9557f5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:52:21 +0800 Subject: [PATCH 093/132] test(commands): refresh ACP goal lifecycle snapshot --- .../tests/goal-snapshots/goal-session/stdout.expected.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 893629bef6..61602dc289 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_create","title":"Create goal","kind":"other","status":"in_progress","rawInput":"Finish the ACP goal-session snapshot proof"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_create","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_get","title":"Read current goal","kind":"read","status":"in_progress"}}} From 46d60d6d9052f6b7beec311693547ac8aeb93a42 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:58:57 +0800 Subject: [PATCH 094/132] fix(tool-subagent): enforce depth only at runtime --- ...-subagent-persona-tool-filter-and-depth.md | 6 +- docs/config-catalog.md | 5 +- docs/core-data-structures/subagent.md | 2 +- docs/glossary.md | 2 +- .../acp-agent/depth-two.cordis.snapshot.yml | 36 ++++++ examples/acp-agent/depth-two.cordis.yml | 13 ++ examples/acp-agent/tests/acp.snapshot.ts | 21 ++-- .../subagent-depth-two-rejection/input.json | 7 ++ .../replay.override.json | 22 ++++ .../session.1.jsonl | 23 ++++ .../session.2.jsonl | 23 ++++ .../session.jsonl | 23 ++++ .../stdout.expected.jsonl | 6 + .../tests/subagent-inprocess.spec.ts | 5 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 30 +---- .../tool-subagent/tests/tool-subagent.spec.ts | 45 ++----- packages/support/acp-snapshot/src/suite.ts | 57 +-------- .../suite/child-omission/behavior.json | 116 ------------------ .../fixtures/suite/child-omission/input.json | 1 - .../suite/child-omission/session.1.jsonl | 2 - .../suite/child-omission/session.2.jsonl | 2 - .../suite/child-omission/session.jsonl | 3 - .../child-omission/stdout.expected.jsonl | 5 - .../suite/child-omission/workspace/seed.txt | 1 - .../support/acp-snapshot/tests/suite.spec.ts | 36 ------ 26 files changed, 192 insertions(+), 302 deletions(-) create mode 100644 examples/acp-agent/depth-two.cordis.snapshot.yml create mode 100644 examples/acp-agent/depth-two.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.expected.jsonl delete mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 6e4b388a26..ee7af6c20c 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -53,7 +53,7 @@ The effective parent depth is the greater of durable `SessionHeader.delegationDe Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. -A deployment can combine depth and filtering. When a numeric tool cap and `toolFilter` are supported, `dsh-tool-subagent` denies its configured tool name in a child whose derived depth is at the cap; the provider's independent depth check still rejects direct or alternate starts beyond it. A deployment may also deny delegation tools entirely in children. Neither choice changes the provider's conversation-history behavior. +A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. ### Capability gating keeps providers honest @@ -85,10 +85,10 @@ A security design would need a separate authority representation, propagation ru **Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through Code Mode or a forged call. One resolver governs both presentation and execution instead. -**Use only tool filtering to stop recursion.** Removing the delegation tool is useful but provider-specific and does not protect direct service callers or alternate delegation tools. Absolute depth is an independent structural bound. +**Encode the depth cap as an automatic tool filter.** A creation-time filter snapshots a decision that may depend on runtime state, affects only one configured tool name, and does not protect direct service callers or alternate delegation tools. The provider instead enforces the absolute cap at every start. ## Consequences Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift. -The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation. +The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. A model may call a visible delegation tool after the current depth policy forbids another child and receive an error. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d88718229c..bb07c83551 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1183,9 +1183,8 @@ export interface Config { * Maximum child depth: a non-negative safe integer (default `3`; `0` forbids * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap * requires the provider's `depthLimit` capability (mount fails loud - * otherwise), and a child AT the cap additionally loses this tool from its - * schema when the provider supports `toolFilter` — the prompt face of the - * budget; the service keeps rejecting on the execution face. + * otherwise). The provider checks the calling agent's current depth at every + * start; the tool remains model-visible so runtime policy owns rejection. * `'provider-managed'` is for an out-of-process provider (ACP) whose * recursion budget belongs to the child harness's own deployment. */ diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 54e79aca68..bd4324e697 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -237,5 +237,5 @@ interface SubagentProvider { The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: -- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child. +- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/glossary.md b/docs/glossary.md index 5392773dc9..0bed6fc56e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -14,4 +14,4 @@ FIXME(glossary-completeness): Expand this glossary before the first release so i - **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism. - **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool surface for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one. - **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent. -- **lineage** — parent/child facts carried as data (`parentSession`, `subagentDepth`); never affects visibility. +- **lineage** — parent/child facts carried as data (`parentSession`, durable `delegationDepth`, runtime `subagentDepth`); never affects visibility. diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml new file mode 100644 index 0000000000..16aa6f587c --- /dev/null +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless counterpart to depth-two.cordis.yml: apply the depth patch and replace +# the live adapter with per-session replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 2 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml new file mode 100644 index 0000000000..25b0ee8e38 --- /dev/null +++ b/examples/acp-agent/depth-two.cordis.yml @@ -0,0 +1,13 @@ +# Depth-limit snapshot overlay: keep the default composition and allow two +# generations of spawn children before runtime enforcement rejects another. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 2 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 849c2230d4..88ae18222c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -30,6 +30,7 @@ const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) +const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -105,12 +106,17 @@ const SCENARIOS: Scenario[] = [ }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true }, - // Children sit at this example's configured depth cap (`maxDepth: 1`), so each child's header - // legitimately omits the delegation tool that spawned it (schema hiding). - { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent'] }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent_fork'] }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childToolOmissions: ['subagent', 'subagent_fork'] }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, + { + name: 'subagent-depth-two-rejection', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: DEPTH_TWO_CONFIG, + }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. @@ -125,9 +131,6 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'advanced', configPath: ADVANCED_CONFIG, - // The direct spawn child sits at the configured cap and loses `subagent`; - // workflow children bypass tool-subagent and keep the full set. - childToolOmissions: ['subagent'], }, { name: 'cordis-inspect-jsdoc', diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/input.json b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/input.json new file mode 100644 index 0000000000..414a33affe --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/replay.override.json b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/replay.override.json new file mode 100644 index 0000000000..ea012cfb63 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_root_child", "name": "subagent", "argumentsDelta": "{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_root_child", "name": "subagent", "arguments": "{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "ROOT_DONE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "ROOT_DONE" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl new file mode 100644 index 0000000000..b7017c51f3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"/tmp/subagent-depth-two","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1784540790312,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784540790312,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784540790318,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784540790318,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1784540790318,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1784540790319,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} +{"type":"tool/result","seq":11,"time":1784540790362,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1784540790363,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1784540790364,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} +{"type":"assistant/chunk","seq":16,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1784540790365,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1784540790365,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":1784540790365,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl new file mode 100644 index 0000000000..7b36136970 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"/tmp/subagent-depth-two","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} +{"type":"turn/start","seq":0,"time":1784540790319,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784540790319,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784540790334,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784540790334,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1784540790335,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1784540790335,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} +{"type":"tool/result","seq":11,"time":1784540790337,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1784540790338,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1784540790338,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} +{"type":"assistant/chunk","seq":16,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} +{"type":"assistant/chunk","seq":17,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1784540790339,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DEPTH_REJECTED"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1784540790339,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":1784540790339,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl new file mode 100644 index 0000000000..b9bdc1baea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"/tmp/subagent-depth-two","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1784540790290,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784540790291,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784540790308,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784540790308,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_root_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":1784540790309,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1784540790310,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1784540790310,"data":{"turn":1,"step":1,"callId":"call_root_child","name":"subagent","arguments":"{\"description\":\"Start depth one\",\"prompt\":\"Call subagent once. Ask that child to attempt one further subagent call, then report the result.\"}"}} +{"type":"tool/result","seq":11,"time":1784540790381,"data":{"turn":1,"step":1,"callId":"call_root_child","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1784540790382,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1784540790382,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ROOT_DONE"}}} +{"type":"assistant/chunk","seq":16,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ROOT_DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":1784540790383,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1784540790383,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"ROOT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1784540790383,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":1784540790383,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl new file mode 100644 index 0000000000..7f629a2d71 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 9c645bc78a..e82c1ad028 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -69,9 +69,8 @@ describe('startInProcessRun', () => { }) it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => { - // The review-reproduced failure chain: a depth-1 child comes back from - // persistence with a fresh AgentOptions (no subagentDepth). Its header must - // stay authoritative, or maxDepth: 1 would let it delegate as top-level. + // Resume rebuilds runtime options, so the durable header must keep this + // depth-1 child from delegating as though it were top-level. const { ctx } = await setup([textResponse('unused')]) const resumed = (await ctx.agents.create({ sessionId: SessionId('resumed-child'), diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 8121531a7b..e2af5ac552 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -22,7 +22,7 @@ With `run_in_background: true`, the tool registers the parent-owned task before | `agentOptions` | Default child options, currently including `model`. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | -| `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap — for an out-of-process provider whose budget belongs to the child harness. A child AT the cap also loses this tool from its schema when the provider supports `toolFilter` (prompt-face hiding; the service still rejects on the execution face). | +| `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap for an out-of-process provider whose budget belongs to the child harness. The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected. | ## Concurrency diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 8eae576f09..bcd28c6ab1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,7 +12,7 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { assertSubagentMaxDepth, delegationDepthOf } from '@deepseek-ai/dsh-subagent' +import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' @@ -57,9 +57,8 @@ export interface Config { * Maximum child depth: a non-negative safe integer (default `3`; `0` forbids * delegation entirely), or `'provider-managed'` to send no cap. A numeric cap * requires the provider's `depthLimit` capability (mount fails loud - * otherwise), and a child AT the cap additionally loses this tool from its - * schema when the provider supports `toolFilter` — the prompt face of the - * budget; the service keeps rejecting on the execution face. + * otherwise). The provider checks the calling agent's current depth at every + * start; the tool remains model-visible so runtime policy owns rejection. * `'provider-managed'` is for an out-of-process provider (ACP) whose * recursion budget belongs to the child harness's own deployment. */ @@ -199,28 +198,15 @@ function providerWording(inheritsConversation: boolean): { description: string; } } -function startRequest( - config: Config, - prompt: string, - parent: Agent, - signal: AbortSignal, - hideAtCapToolName: string | undefined, -): SubagentStartRequest { +function startRequest(config: Config, prompt: string, parent: Agent, signal: AbortSignal): SubagentStartRequest { const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined - // A child AT the cap cannot delegate further: deny it this tool so its - // schema hides what the service would reject anyway (prompt face; the - // depth check at start remains the execution face). - const childAtCap = maxDepth !== undefined && delegationDepthOf(parent) + 1 >= maxDepth - const toolFilter = childAtCap && hideAtCapToolName !== undefined - ? { ...config.toolFilter, deny: [...config.toolFilter?.deny ?? [], hideAtCapToolName] } - : config.toolFilter return { prompt: [{ type: 'text', text: prompt }], parent, signal, ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, - ...toolFilter !== undefined ? { toolFilter } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, ...maxDepth !== undefined ? { maxDepth } : {}, } } @@ -257,9 +243,6 @@ export function apply(ctx: Context, config: Config): void { + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider', ) } - // Schema hiding rides the child toolFilter, so it needs that capability; - // without it the depth check at start remains the only fence. - const hideAtCapToolName = provider.capabilities.toolFilter ? config.toolName ?? 'subagent' : undefined const wording = providerWording(provider.inheritsParentContext) const backgroundEnabled = config.enableRunInBackground !== false disposeTool = ctx.tools.register(defineTool({ @@ -314,7 +297,7 @@ export function apply(ctx: Context, config: Config): void { const controller = new AbortController() const start = ctx.subagents.start( config.provider, - startRequest(config, args.prompt, parent, controller.signal, hideAtCapToolName), + startRequest(config, args.prompt, parent, controller.signal), ) return { cancel: (reason?: string) => { @@ -333,7 +316,6 @@ export function apply(ctx: Context, config: Config): void { args.prompt, parent, exec.signal ?? new AbortController().signal, - hideAtCapToolName, ) const run: SubagentRun = await ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 7aa8f10d45..9e4d68c08c 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -23,13 +23,9 @@ import { SessionId } from '@deepseek-ai/dsh-session' * shipping code path. */ -/** A minimal parent Agent: the tool reads `agent.id` plus the delegation depth off its header/options. */ -function fakeAgent(id = 'parent-1', delegationDepth?: number): Agent { - return { - id: SessionId(id), - options: {}, - session: { header: { ...delegationDepth === undefined ? {} : { delegationDepth } } }, - } as unknown as Agent +/** A minimal parent Agent passed through to the provider request. */ +function fakeAgent(id = 'parent-1'): Agent { + return { id: SessionId(id) } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -886,7 +882,7 @@ describe('background preflight failure (no orphaned child, by construction)', () }) }) -describe('depth budget defaults and schema hiding', () => { +describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] @@ -916,37 +912,14 @@ describe('depth budget defaults and schema hiding', () => { const { ctx, requests } = await captureSetup() await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(requests[0]?.maxDepth).toBe(3) - expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent') + expect(requests[0]?.toolFilter).toBeUndefined() }) - it('denies its own toolName to a child at the depth cap', async () => { - // The child of a depth-0 parent under maxDepth 1 sits AT the cap: any - // delegation it attempted would be rejected, so the tool must not appear in - // its schema at all (prompt-face hiding; the service still rejects). - const { ctx, requests } = await captureSetup({ maxDepth: 1 }) + it('forwards an explicit tool filter unchanged instead of encoding the depth policy into it', async () => { + const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] }, maxDepth: 0 }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(requests[0]?.toolFilter?.deny).toContain('subagent') - }) - - it('merges the cap denial into a configured tool filter', async () => { - const { ctx, requests } = await captureSetup({ toolFilter: { deny: ['dangerous'] }, maxDepth: 1 }) - await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(requests[0]?.toolFilter?.deny).toEqual(expect.arrayContaining(['dangerous', 'subagent'])) - }) - - it('keeps the tool visible for a child below the cap', async () => { - const { ctx, requests } = await captureSetup({ maxDepth: 2 }) - await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(requests[0]?.maxDepth).toBe(2) - expect(requests[0]?.toolFilter?.deny ?? []).not.toContain('subagent') - }) - - it('counts the parent by its persisted header depth when hiding', async () => { - // A resumed depth-1 parent under maxDepth 2: its child is AT the cap and - // must lose the tool even though the parent's runtime options carry no depth. - const { ctx, requests } = await captureSetup({ maxDepth: 2 }) - await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: fakeAgent('resumed-parent', 1) }) - expect(requests[0]?.toolFilter?.deny).toContain('subagent') + expect(requests[0]?.maxDepth).toBe(0) + expect(requests[0]?.toolFilter).toEqual({ deny: ['dangerous'] }) }) it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => { diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c24d292913..60f5cfeadb 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -100,18 +100,6 @@ export interface Scenario { * {@link headerClass}. */ configPath?: string - /** - * Global tool names allowed to be ABSENT from a non-primary (child) session's - * request/header relative to the class pin — the delegation tool a child at - * its depth cap loses to tool-subagent's schema hiding. Each child header is - * compared against the pin minus exactly the declared names it actually - * omitted, so any other divergence (or an undeclared omission) still fails. - * A child that omitted a declared tool also skips the text-level initial - * system prompt pin: the prompt embeds the toolset (Code Mode SDK sections), - * so a reduced child cannot equal the full-composition expected output — the - * structural header assertion remains its pin. Meaningless on the primary log. - */ - childToolOmissions?: string[] } /** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */ @@ -294,36 +282,6 @@ export function restorePinnedToolSchemas(header: unknown, schemas: readonly unkn return { ...header, tools: schemas } } -/** - * The pinned header with exactly the DECLARED omissions a child actually made - * removed from its tool list. A child at its depth cap legitimately lacks the - * delegation tool that spawned it (tool-subagent schema hiding); removing only - * declared-AND-actually-absent names keeps every other divergence — including - * an undeclared omission — a loud mismatch. - * @param pinned The class-pinned full header (tool schemas restored). - * @param actual The child session's normalized header under comparison. - * @param allowed The scenario's declared {@link Scenario.childToolOmissions}. - * @returns The expected header for this child log. - */ -export function applyChildToolOmissions(pinned: unknown, actual: unknown, allowed: readonly string[]): unknown { - if (pinned === null || typeof pinned !== 'object' || Array.isArray(pinned)) { - throw new Error('acp-snapshot: pinned request header must be an object') - } - const toolNames = (header: unknown): Set => { - const tools = (header as { tools?: unknown }).tools - return new Set(Array.isArray(tools) - ? tools.map(tool => (tool as { name?: unknown }).name).filter((name): name is string => typeof name === 'string') - : []) - } - const actualNames = toolNames(actual) - const pinnedTools = (pinned as { tools?: unknown[] }).tools ?? [] - const tools = pinnedTools.filter((tool) => { - const name = (tool as { name?: unknown }).name - return !(typeof name === 'string' && allowed.includes(name) && !actualNames.has(name)) - }) - return { ...pinned, tools } -} - /** * Render a normalized prompt as a repository-friendly Markdown snapshot. * Prompt text is unchanged except that a missing terminal newline is added so @@ -667,20 +625,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(headers.length) for (const [k, header] of headers.entries()) { const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0] - // A child (non-primary) log may omit declared delegation tools — - // schema hiding at the depth cap; see Scenario.childToolOmissions. - const childOmissions = logIndex === 0 ? [] : scenario.childToolOmissions ?? [] - const target = childOmissions.length === 0 - ? expected - : applyChildToolOmissions(expected, header, childOmissions) expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) - .toEqual(target) - // A child that omitted a declared tool cannot equal the text-level - // prompt pin (the prompt embeds the toolset); its header assertion - // above remains the structural pin. - const omittedDeclaredTool = target !== expected - && (target as { tools?: unknown[] }).tools?.length !== (expected as { tools?: unknown[] }).tools?.length - if (expectedChanges === 0 && !omittedDeclaredTool) { + .toEqual(expected) + if (expectedChanges === 0) { expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(initialPromptSnapshot) } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json deleted file mode 100644 index b8624d4673..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/behavior.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "prompt": "respond", - "echoWorkspace": true, - "logs": [ - { - "file": "b/parent.jsonl", - "lines": [ - { - "type": "session", - "id": "{{SID}}", - "createdAt": 200, - "cwd": "{{CWD}}" - }, - { - "type": "request/header", - "seq": 0, - "time": 5, - "data": { - "header": { - "config": { - "model": "fake" - }, - "system": "SYS PROMPT", - "tools": [ - { - "name": "t1", - "description": "D1", - "parameters": { - "type": "object" - } - } - ] - }, - "reason": "initial" - } - }, - { - "type": "assistant/chunk", - "seq": 1, - "time": 5, - "data": { - "turn": 1, - "step": 1, - "chunk": { - "type": "text-delta", - "index": 0, - "text": "hi" - } - } - } - ] - }, - { - "file": "b/child1.jsonl", - "lines": [ - { - "type": "session", - "id": "eeeeeeee-1111-4222-8333-444444444444", - "createdAt": 300, - "cwd": "{{CWD}}", - "parentSession": "{{SID}}" - }, - { - "type": "request/header", - "seq": 0, - "time": 6, - "data": { - "header": { - "config": { - "model": "fake" - }, - "system": "SYS PROMPT", - "tools": [] - }, - "reason": "initial" - } - } - ] - }, - { - "file": "b/child2.jsonl", - "lines": [ - { - "type": "session", - "id": "ffffffff-2222-4333-8444-555555555555", - "createdAt": 400, - "cwd": "{{CWD}}", - "parentSession": "{{SID}}" - }, - { - "type": "request/header", - "seq": 0, - "time": 6, - "data": { - "header": { - "config": { - "model": "fake" - }, - "system": "SYS PROMPT", - "tools": [ - { - "name": "t1", - "description": "D1", - "parameters": { - "type": "object" - } - } - ] - }, - "reason": "initial" - } - } - ] - } - ] -} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json deleted file mode 100644 index 60b9e363b5..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/input.json +++ /dev/null @@ -1 +0,0 @@ -{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "plain" }] } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl deleted file mode 100644 index a844f891fc..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.1.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"} -{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl deleted file mode 100644 index c3bd629ad7..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.2.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"session","id":"ffffffff-2222-4333-8444-555555555555","createdAt":13,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"} -{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl deleted file mode 100644 index 744998f959..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/session.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd"} -{"type":"request/header","seq":0,"time":11,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":1,"time":11,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"hi"}}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.expected.jsonl deleted file mode 100644 index d0242ae39f..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/stdout.expected.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt b/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt deleted file mode 100644 index c19e887d68..0000000000 --- a/packages/support/acp-snapshot/tests/fixtures/suite/child-omission/workspace/seed.txt +++ /dev/null @@ -1 +0,0 @@ -seeded diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 6eb04241bb..ac6f944a4a 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -16,7 +16,6 @@ import { parseToolSchemasSnapshot, refreshFixtureReplacements, sessionFixtureNames, - applyChildToolOmissions, restorePinnedToolSchemas, stabilizeRefreshLog, unknownToolCallIds, @@ -48,10 +47,6 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, - // Two scripted children under a declared omission: one omits t1 (header pin - // minus the declared tool, prompt pin skipped), one keeps the full set (pin - // and prompt compared verbatim) — the childToolOmissions branches. - { name: 'child-omission', hasModelTurn: true, recorded: false, headerClass: 'main', childToolOmissions: ['t1'] }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, @@ -371,37 +366,6 @@ describe('tool-schema snapshots', () => { }) }) -describe('applyChildToolOmissions', () => { - const pinned = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent' }, { name: 'subagent_fork' }] } - - it('removes exactly the declared tools the child actually omitted', () => { - const actual = { system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] } - expect(applyChildToolOmissions(pinned, actual, ['subagent', 'subagent_fork'])) - .toEqual({ system: 's', tools: [{ name: 'bash' }, { name: 'subagent_fork' }] }) - }) - - it('keeps a declared tool the child still carries and an undeclared omission', () => { - // The child omitted `bash` (undeclared) — the expectation keeps it, so the - // equality assertion downstream still fails loudly on the real divergence. - const actual = { system: 's', tools: [{ name: 'subagent' }, { name: 'subagent_fork' }] } - expect(applyChildToolOmissions(pinned, actual, ['subagent'])) - .toEqual(pinned) - }) - - it('tolerates a headerless tool list and unnamed tool entries', () => { - expect(applyChildToolOmissions({ system: 's' }, { tools: 'not-an-array' }, ['subagent'])) - .toEqual({ system: 's', tools: [] }) - const unnamed = { system: 's', tools: [{ name: 42 }] } - expect(applyChildToolOmissions(unnamed, { tools: [] }, ['subagent'])).toEqual(unnamed) - }) - - it('rejects a non-object pinned header', () => { - expect(() => applyChildToolOmissions(null, {}, [])).toThrow(/must be an object/) - expect(() => applyChildToolOmissions([], {}, [])).toThrow(/must be an object/) - expect(() => applyChildToolOmissions('x', {}, [])).toThrow(/must be an object/) - }) -}) - describe('unknownToolCallIds', () => { it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => { const log = [ From 41d3e78d3a48662b1dfbbac937bad50c638f9dcd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:59:13 +0800 Subject: [PATCH 095/132] test(snapshots): require explicit delegation depth --- .../tests/fixtures/live-mode-switching-2026-07-07.session.jsonl | 2 +- .../acp-agent/tests/snapshots/advanced-toolchain/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/bash-spill/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl | 2 +- .../acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/cancel/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl | 2 +- .../tests/snapshots/code-mode-workspace-context/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/config-options/session.jsonl | 2 +- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/error-finish/session.jsonl | 2 +- .../acp-agent/tests/snapshots/escalation-approved/session.jsonl | 2 +- .../acp-agent/tests/snapshots/escalation-rejected/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/fs-edit/session.jsonl | 2 +- .../tests/snapshots/fs-escalation-approved/session.jsonl | 2 +- .../acp-agent/tests/snapshots/fs-policy-reject/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/fs-read/session.jsonl | 2 +- .../acp-agent/tests/snapshots/fs-terminal-card/session.jsonl | 2 +- .../acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/fs-write/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/handshake/session.jsonl | 2 +- .../tests/snapshots/hook-cc-posttool-block/session.jsonl | 2 +- .../tests/snapshots/hook-cc-posttool-context/session.jsonl | 2 +- .../acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl | 2 +- .../tests/snapshots/hook-cc-pretool-deny/session.jsonl | 2 +- .../tests/snapshots/hook-cc-promptsubmit-block/session.jsonl | 2 +- .../tests/snapshots/hook-cc-promptsubmit-context/session.jsonl | 2 +- .../tests/snapshots/hook-cc-stop-continue/session.jsonl | 2 +- .../tests/snapshots/hook-codex-posttool-block/session.jsonl | 2 +- .../tests/snapshots/hook-codex-posttool-context/session.jsonl | 2 +- .../tests/snapshots/hook-codex-pretool-block/session.jsonl | 2 +- .../tests/snapshots/hook-codex-promptsubmit-block/session.jsonl | 2 +- .../snapshots/hook-codex-promptsubmit-context/session.jsonl | 2 +- .../tests/snapshots/hook-codex-stop-continue/session.jsonl | 2 +- .../acp-agent/tests/snapshots/model-switching/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/multi-turn/session.jsonl | 2 +- .../acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl | 2 +- .../tests/snapshots/permission-switching/session.jsonl | 2 +- .../acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl | 2 +- .../acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/text-turn/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/todo-plan/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/workflow-run/session.jsonl | 2 +- .../acp-agent/tests/snapshots/workspace-context/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.jsonl | 2 +- .../tui-agent/tests/snapshots/bash-terminal-card/session.jsonl | 2 +- examples/tui-agent/tests/snapshots/code-mode/session.jsonl | 2 +- .../tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl | 2 +- .../tests/snapshots/cordis-dynamic-toolchain/session.jsonl | 2 +- .../tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl | 2 +- .../tui-agent/tests/snapshots/dynamic-workflow/session.jsonl | 2 +- .../tests/snapshots/multi-turn-conversation/session.jsonl | 2 +- .../tui-agent/tests/snapshots/parallel-file-reads/session.jsonl | 2 +- examples/tui-agent/tests/snapshots/todo-plan/session.jsonl | 2 +- .../tests/fixtures/record-suite/rec-child/session.1.jsonl | 2 +- .../tests/fixtures/record-suite/rec-child/session.jsonl | 2 +- .../tests/fixtures/record-suite/rec-pin/session.jsonl | 2 +- .../tests/fixtures/record-suite/rec-skip/session.jsonl | 2 +- .../tests/fixtures/suite/authored-error/session.jsonl | 2 +- .../acp-snapshot/tests/fixtures/suite/blocked-log/session.jsonl | 2 +- .../acp-snapshot/tests/fixtures/suite/no-model/session.jsonl | 2 +- .../acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl | 2 +- .../tests/fixtures/suite/plain-turn/session.1.jsonl | 2 +- .../acp-snapshot/tests/fixtures/suite/plain-turn/session.jsonl | 2 +- 73 files changed, 73 insertions(+), 73 deletions(-) diff --git a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl index 0852ef7513..10ffb8c507 100644 --- a/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl +++ b/examples/acp-agent/tests/fixtures/live-mode-switching-2026-07-07.session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ed16a7e7-a76f-459f-b889-d4c424d66ef6","createdAt":1783421406247,"cwd":"/Users/wwl/workspace/deepseek-harness"} +{"type":"session","version":0,"id":"ed16a7e7-a76f-459f-b889-d4c424d66ef6","createdAt":1783421406247,"cwd":"/Users/wwl/workspace/deepseek-harness","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783421410388,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783421410388,"data":{"content":[{"type":"text","text":"你好"}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783421410389,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 39f867984a..46c8e41257 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 853b446f81..65f92c4916 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the bash tool to print a large deterministic output, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 0ee3e67e2b..6b704c5bcd 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"bcd7e943-7b84-4264-82d0-f64e50d0d7ce","createdAt":1783611774317,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-52lrTl"} +{"type":"session","version":0,"id":"bcd7e943-7b84-4264-82d0-f64e50d0d7ce","createdAt":1783611774317,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-52lrTl","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index e6d18515a6..cdb76be163 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784437195076,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 347951db62..44e8ac136c 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a long task; this turn will be cancelled mid-stream."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 0fe57055e8..8ee160ba26 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR"} +{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index a20dbcd999..4f72ffa498 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26"} +{"type":"session","version":0,"id":"65fbb8a6-624c-4d6a-bf5d-a7a7d14f2b49","createdAt":1783921765266,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783921765269,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783921765269,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783921765275,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/config-options/session.jsonl b/examples/acp-agent/tests/snapshots/config-options/session.jsonl index a6f73319bc..63f2775383 100644 --- a/examples/acp-agent/tests/snapshots/config-options/session.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"delegationDepth":0} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index dc9e4a844a..207f8d8cc3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"/tmp/cordis-inspect-jsdoc"} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"/tmp/cordis-inspect-jsdoc","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784449176720,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl index f0ef4267ac..6b6c44ebff 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/session.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt triggers a recorded provider error."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index b3931b079b..354a014a63 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2"} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962245380,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518116517,"data":{"mode":"workspace-write"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index af1afd3c4a..ff6d1187b3 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od"} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962246267,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518117237,"data":{"mode":"workspace-write"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 00587e34c2..25992426b8 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"/tmp/acp-snap-cwd-0BxHdV"} +{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"/tmp/acp-snap-cwd-0BxHdV","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352084740,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352084740,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352084742,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index f796b0b473..54601f5354 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd"} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1784045702343,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784045702343,"data":{"mode":"workspace-write"}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 03a64d2e39..66efd5f934 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB"} +{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611702550,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611702550,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611702550,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index e587011cfd..f0e1f170ac 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"/tmp/acp-snap-cwd-N9HCkt"} +{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"/tmp/acp-snap-cwd-N9HCkt","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352099838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352099839,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352099840,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 81737364f8..5582896174 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"/tmp/acp-snap-cwd-PEETkS"} +{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"/tmp/acp-snap-cwd-PEETkS","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352072468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352072469,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352072470,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl index e53f4b3da3..5c3bba5676 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk"} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 08ec0f1323..ab8b5965ad 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"/tmp/acp-snap-cwd-hH2sGY"} +{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"/tmp/acp-snap-cwd-hH2sGY","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352092220,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352092221,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352092223,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 59ba868817..04fd86da0d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"/tmp/acp-snap-cwd-sNvn5N"} +{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"/tmp/acp-snap-cwd-sNvn5N","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352078754,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352078754,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352078756,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/handshake/session.jsonl b/examples/acp-agent/tests/snapshots/handshake/session.jsonl index a6f73319bc..63f2775383 100644 --- a/examples/acp-agent/tests/snapshots/handshake/session.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"delegationDepth":0} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index be1d70a853..c362de7a26 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY"} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783962504152,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index 2e167db28c..66d2a6b427 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"/tmp/acp-snap-cwd-LEetSL"} +{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"/tmp/acp-snap-cwd-LEetSL","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352196662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352196662,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352196664,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index a1f46a781b..247e13a075 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS"} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352171527,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 9633075e5b..4193c7fe80 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo"} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352165198,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl index b5f81fdaea..dd6745b770 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook","durationMs":0}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 886761da18..d2a0806829 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"/tmp/acp-snap-cwd-QUDqlk"} +{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"/tmp/acp-snap-cwd-QUDqlk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783352160546,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352160564,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":17.45639600000004}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index baa6a92e9a..c03ee5bd5d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-r6rWZp"} +{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-r6rWZp","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784522140648,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 6d08c3ee7d..dc68891c14 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP"} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783986962240,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index d60858ee0a..b7e97b3a65 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"/tmp/acp-snap-cwd-VGFtPi"} +{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"/tmp/acp-snap-cwd-VGFtPi","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352228441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352228442,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352228443,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 812998aad5..c2675ae3af 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"/tmp/acp-snap-cwd-7Hbu0m"} +{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"/tmp/acp-snap-cwd-7Hbu0m","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352214604,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352214605,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352214607,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl index bc9144f980..126a761309 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook","durationMs":0}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 1c710ff8e1..ad7538cba9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"/tmp/acp-snap-cwd-aopaZV"} +{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"/tmp/acp-snap-cwd-aopaZV","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783352209687,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} {"type":"hook/result","seq":2,"time":1783352209706,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":19.49695100000008}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index cee758076c..5cf14d9b49 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-ESgqLu"} +{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-ESgqLu","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784522152399,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/session.jsonl b/examples/acp-agent/tests/snapshots/model-switching/session.jsonl index 1f04eca07d..ddc592bbb6 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"622d16ce-0a94-476b-97a4-26dad50b1fbf","createdAt":1784086275585,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Cwf7Bh"} +{"type":"session","version":0,"id":"622d16ce-0a94-476b-97a4-26dad50b1fbf","createdAt":1784086275585,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Cwf7Bh","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784086275588,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784086275588,"data":{"content":[{"type":"text","text":"Without using tools, reply with exactly FLASH and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784086275590,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index cd32072eaa..83ccf18a3f 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR"} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl index 81503dc4cf..e83f0cd59c 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index 43fc43d979..ff44da7e1d 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"df041acb-2f14-4d5f-b6e2-2fb6b9eb6427","createdAt":1783860666204,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-4oJKT4"} +{"type":"session","version":0,"id":"df041acb-2f14-4d5f-b6e2-2fb6b9eb6427","createdAt":1783860666204,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-4oJKT4","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860666206,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"permission/preset","seq":1,"time":1783962244578,"data":{"preset":"workspace-write"}} {"type":"sandbox/mode","seq":2,"time":1784518115721,"data":{"mode":"workspace-write"}} diff --git a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl index a6f73319bc..63f2775383 100644 --- a/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl +++ b/examples/acp-agent/tests/snapshots/reject-extra-dirs/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0} +{"type":"session","version":0,"id":"00000000-0000-0000-0000-000000000000","createdAt":0,"delegationDepth":0} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl index 20d18973d9..c77e0ef38d 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 0fe1a5152c..1a9704954a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW"} +{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index 78846dd11f..5fb8854903 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","createdAt":1784451778257,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie"} +{"type":"session","version":0,"id":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","createdAt":1784451778257,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784451778261,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784451778262,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784451778263,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index ee9b134de6..e5029f4248 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","createdAt":1784451785949,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm"} +{"type":"session","version":0,"id":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","createdAt":1784451785949,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784451785951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784451785952,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784451785955,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 9649a197bd..75a7460776 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","createdAt":1784451767994,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm"} +{"type":"session","version":0,"id":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","createdAt":1784451767994,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784451767996,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784451767996,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784451768000,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index 5dddaa522f..c46bf5237b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5ab41657-0a0f-4317-88fe-451c5197cdb4","createdAt":1784451761926,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C"} +{"type":"session","version":0,"id":"5ab41657-0a0f-4317-88fe-451c5197cdb4","createdAt":1784451761926,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784451761930,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784451761930,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784451761932,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 04ac2f9794..1f2209ca19 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w"} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/session.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/session.jsonl index 909afc44cd..cea8a4fa88 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7"} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index f631c9bf54..3b0e93b464 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"/tmp/acp-snap-cwd-OwUkBh"} +{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"/tmp/acp-snap-cwd-OwUkBh","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352044771,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352044771,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352044773,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 55b622307d..a520e28d08 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"6789922c-5a8c-4141-8336-0f9b0809bb17","createdAt":1784451802866,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5"} +{"type":"session","version":0,"id":"6789922c-5a8c-4141-8336-0f9b0809bb17","createdAt":1784451802866,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784451802869,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784451802870,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1784451802872,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index f50e124b32..9e08a7800b 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783778297069,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 6b9b03a95e..4a5b37a374 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"/tmp/acp-snap-cwd-rxbEpP"} +{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"/tmp/acp-snap-cwd-rxbEpP","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352264080,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352264081,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352264082,"data":{"turn":1,"step":1}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index e3849110ce..045d7879df 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless"} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl index e53f4b3da3..5c3bba5676 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk"} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl index 0fe57055e8..8ee160ba26 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR"} +{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl index 25a6f76411..26519d458e 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl index 45d9043a4a..9daa8958dc 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index 39f867984a..46c8e41257 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl index 3d89428bbd..eb8d9ed63e 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8"} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl index 3e0ae3da73..20f4e296cd 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz"} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl index cd32072eaa..83ccf18a3f 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR"} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl index 81503dc4cf..e83f0cd59c 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl index 909afc44cd..cea8a4fa88 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7"} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl index 1caf2610b3..4fa81014ae 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.1.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88"} +{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","delegationDepth":1} {"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl index a2beac360d..e972a78d8e 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/session.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW"} +{"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","delegationDepth":0} {"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl index e9dd3fb16c..40bb4d37dc 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"ccdc749f-56f3-4267-9750-598b5c60b7b2","createdAt":600,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-nOQ4Gy"} +{"type":"session","id":"ccdc749f-56f3-4267-9750-598b5c60b7b2","createdAt":600,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-nOQ4Gy","delegationDepth":0} {"type":"request/header","seq":0,"time":4,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/session.jsonl index 104f2a0df2..035d1353b7 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/session.jsonl @@ -1 +1 @@ -{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/session.jsonl index 36991a214e..a202c7141d 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/session.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"44444444-3333-4222-8111-000000000000","createdAt":17,"cwd":"/rec/authored-cwd"} +{"type":"session","id":"44444444-3333-4222-8111-000000000000","createdAt":17,"cwd":"/rec/authored-cwd","delegationDepth":0} {"type":"turn/end","seq":1,"time":17,"data":{"error":"model exploded"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/session.jsonl index 6d8474812d..75eb18fa71 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/session.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd"} +{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd","delegationDepth":0} {"type":"hook/result","seq":1,"time":13,"data":{"decision":"block","durationMs":99}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/no-model/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/no-model/session.jsonl index 104f2a0df2..035d1353b7 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/no-model/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/no-model/session.jsonl @@ -1 +1 @@ -{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl index 54b64b59a3..693e728d2c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","id":"12121212-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/pin-cwd"} +{"type":"session","id":"12121212-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/pin-cwd","delegationDepth":0} {"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/header","seq":1,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"turn/start","seq":2,"time":7,"data":{"turn":1}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.1.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.1.jsonl index a844f891fc..5fb01dbe8a 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.1.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.1.jsonl @@ -1,2 +1,2 @@ -{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"} +{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","delegationDepth":1} {"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.jsonl index 744998f959..6bdf784ce2 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/session.jsonl @@ -1,3 +1,3 @@ -{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd"} +{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd","delegationDepth":0} {"type":"request/header","seq":0,"time":11,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":1,"time":11,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"hi"}}} From 75e98d697459b96811af51349ac4b27c460231ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:02:38 +0800 Subject: [PATCH 096/132] test(acp): drop obsolete transcript rerecordings --- .../snapshots/subagent-fork/session.1.jsonl | 167 ++--- .../snapshots/subagent-fork/session.jsonl | 363 +++++----- .../subagent-fork/stdout.expected.jsonl | 107 +-- .../snapshots/subagent-mixed/session.1.jsonl | 72 +- .../snapshots/subagent-mixed/session.2.jsonl | 156 ++--- .../snapshots/subagent-mixed/session.jsonl | 622 ++++++++---------- .../subagent-mixed/stdout.expected.jsonl | 204 +++--- .../snapshots/subagent-multi/session.1.jsonl | 72 +- .../snapshots/subagent-multi/session.2.jsonl | 68 +- .../snapshots/subagent-multi/session.jsonl | 402 +++++------ .../subagent-multi/stdout.expected.jsonl | 80 ++- .../snapshots/subagent-spawn/session.1.jsonl | 68 +- .../snapshots/subagent-spawn/session.jsonl | 287 ++++---- .../subagent-spawn/stdout.expected.jsonl | 62 +- .../snapshots/workflow-run/session.1.jsonl | 72 +- .../snapshots/workflow-run/session.jsonl | 352 ++++++---- .../workflow-run/stdout.expected.jsonl | 87 ++- 17 files changed, 1721 insertions(+), 1520 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 18802ec864..0ec70d6f81 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,78 +1,89 @@ -{"type":"session","version":0,"id":"de67f82f-1a81-463e-8388-b323dafb8843","createdAt":1784451782049,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie","parentSession":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","seedLength":33,"delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451778261,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451778262,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451778263,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451778263,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451779947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451779948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} -{"type":"assistant/chunk","seq":13,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":14,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":15,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1784451779958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":17,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":18,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":19,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} -{"type":"assistant/chunk","seq":21,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":22,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":23,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451780035,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":26,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":29,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451780041,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451780041,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451780041,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1784451782052,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":34,"time":1784451782052,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":35,"time":1784451782052,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":36,"time":1784451782052,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":37,"time":1784451783403,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":38,"time":1784451783403,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":39,"time":1784451783502,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":40,"time":1784451783534,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":41,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":42,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":43,"time":1784451783558,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":44,"time":1784451783587,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":45,"time":1784451783587,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":46,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":47,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":48,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":49,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":50,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":51,"time":1784451783617,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":52,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":53,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":54,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} -{"type":"assistant/chunk","seq":55,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":56,"time":1784451783642,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} -{"type":"assistant/chunk","seq":57,"time":1784451783668,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} -{"type":"assistant/chunk","seq":58,"time":1784451783668,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} -{"type":"assistant/chunk","seq":59,"time":1784451783695,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":60,"time":1784451783695,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":61,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":62,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} -{"type":"assistant/chunk","seq":63,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":64,"time":1784451783725,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":65,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":66,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} -{"type":"assistant/chunk","seq":67,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} -{"type":"assistant/chunk","seq":68,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":69,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} -{"type":"assistant/chunk","seq":70,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the codeword \"MARMALADE\" earlier. They're now asking me to recall it."}}}} -{"type":"assistant/chunk","seq":71,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":72,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3331,"outputTokens":32,"cacheReadTokens":0,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":73,"time":1784451783776,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1784451783777,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the codeword \"MARMALADE\" earlier. They're now asking me to recall it."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3331,"outputTokens":32,"cacheReadTokens":0,"reasoningTokens":27}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73],"surfaceOp":"append"} -{"type":"step/end","seq":75,"time":1784451783777,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":76,"time":1784451783777,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"/tmp/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":37,"delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352134840,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":17,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":18,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":19,"time":1783352135712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":20,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":21,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":22,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":23,"time":1783352135739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":24,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":25,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":26,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":27,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":28,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":29,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":30,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} +{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1783352135773,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1783352135773,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":37,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":38,"time":1783352137163,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":39,"time":1783352137163,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":40,"time":1783352137163,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":41,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":43,"time":1783352137961,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":44,"time":1783352137989,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":45,"time":1783352138020,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":46,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":48,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":50,"time":1783352138074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":51,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":52,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":53,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":55,"time":1783352138075,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":56,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":57,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":58,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":60,"time":1783352138103,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":61,"time":1783352138131,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":62,"time":1783352138159,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} +{"type":"assistant/chunk","seq":63,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":64,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":65,"time":1783352138160,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":66,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":67,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":68,"time":1783352138188,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":69,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":70,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":71,"time":1783352138217,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":72,"time":1783352138245,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":73,"time":1783352138246,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":74,"time":1783352138274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":75,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} +{"type":"assistant/chunk","seq":78,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} +{"type":"assistant/chunk","seq":79,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":80,"time":1783352138305,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} +{"type":"assistant/chunk","seq":81,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":82,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":85,"time":1783352138308,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1783352138308,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":87,"time":1783352138308,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index 5fb8854903..60c1e5cc10 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,169 +1,194 @@ -{"type":"session","version":0,"id":"19a0ab16-a36d-49c8-bac2-c1b2208844ad","createdAt":1784451778257,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-CuULie","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784451778261,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451778262,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451778263,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451778263,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451779662,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451779947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451779948,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451779949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} -{"type":"assistant/chunk","seq":13,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":14,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":15,"time":1784451779950,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1784451779958,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":17,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":18,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":19,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1784451779972,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} -{"type":"assistant/chunk","seq":21,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":22,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":23,"time":1784451780009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451780035,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":26,"time":1784451780036,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":29,"time":1784451780037,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451780041,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3568,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451780041,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451780041,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1784451780063,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":34,"time":1784451780063,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":35,"time":1784451780063,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":36,"time":1784451781196,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":37,"time":1784451781197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":38,"time":1784451781296,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":39,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":40,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":41,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":42,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":43,"time":1784451781327,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":44,"time":1784451781354,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":45,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":46,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":47,"time":1784451781355,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":48,"time":1784451781379,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} -{"type":"assistant/chunk","seq":49,"time":1784451781408,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":50,"time":1784451781436,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":51,"time":1784451781437,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} -{"type":"assistant/chunk","seq":52,"time":1784451781437,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} -{"type":"assistant/chunk","seq":53,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":54,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":55,"time":1784451781473,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":56,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":57,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":58,"time":1784451781474,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":59,"time":1784451781490,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":60,"time":1784451781490,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":61,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":62,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":63,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":64,"time":1784451781519,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" MAR"}}} -{"type":"assistant/chunk","seq":65,"time":1784451781546,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":66,"time":1784451781546,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":67,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":68,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":69,"time":1784451781547,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":70,"time":1784451781572,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":71,"time":1784451781573,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} -{"type":"assistant/chunk","seq":72,"time":1784451781573,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":73,"time":1784451781598,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} -{"type":"assistant/chunk","seq":74,"time":1784451781599,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":75,"time":1784451781688,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":76,"time":1784451781688,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":77,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":78,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":79,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":80,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":81,"time":1784451781712,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":82,"time":1784451781736,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":83,"time":1784451781736,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"Recall"}}} -{"type":"assistant/chunk","seq":84,"time":1784451781772,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":85,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":86,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":87,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":88,"time":1784451781773,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":89,"time":1784451781833,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":90,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":91,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":92,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":93,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":94,"time":1784451781834,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":95,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":96,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"What"}}} -{"type":"assistant/chunk","seq":97,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" is"}}} -{"type":"assistant/chunk","seq":98,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":99,"time":1784451781849,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":100,"time":1784451781878,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":101,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":102,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":103,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" mentioned"}}} -{"type":"assistant/chunk","seq":104,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" earlier"}}} -{"type":"assistant/chunk","seq":105,"time":1784451781879,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":106,"time":1784451781906,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":107,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" conversation"}}} -{"type":"assistant/chunk","seq":108,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"?"}}} -{"type":"assistant/chunk","seq":109,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" Reply"}}} -{"type":"assistant/chunk","seq":110,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":111,"time":1784451781907,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":112,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" that"}}} -{"type":"assistant/chunk","seq":113,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" one"}}} -{"type":"assistant/chunk","seq":114,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":115,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":116,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":117,"time":1784451781934,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":118,"time":1784451781991,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":119,"time":1784451781991,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":120,"time":1784451782006,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":121,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to ask the child agent about the project codeword. The codeword is MARMALADE. Let me delegate this task."}}}} -{"type":"assistant/chunk","seq":122,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":123,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":201,"outputTokens":126,"cacheReadTokens":3456,"reasoningTokens":38}}}} -{"type":"assistant/chunk","seq":124,"time":1784451782045,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":125,"time":1784451782046,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to ask the child agent about the project codeword. The codeword is MARMALADE. Let me delegate this task."},{"type":"tool-call","id":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":201,"outputTokens":126,"cacheReadTokens":3456,"reasoningTokens":38}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124],"surfaceOp":"append"} -{"type":"tool/call","seq":126,"time":1784451782047,"data":{"turn":2,"step":1,"callId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":127,"time":1784451783786,"data":{"turn":2,"step":1,"callId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","content":[{"type":"text","text":"MARMALADE"}],"isError":false},"sourceEventSeqs":[126],"surfaceOp":"append"} -{"type":"step/end","seq":128,"time":1784451783786,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":129,"time":1784451783787,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":130,"time":1784451784950,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":131,"time":1784451784950,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":132,"time":1784451785105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":133,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":134,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":135,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":136,"time":1784451785140,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} -{"type":"assistant/chunk","seq":137,"time":1784451785141,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} -{"type":"assistant/chunk","seq":138,"time":1784451785259,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":139,"time":1784451785259,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} -{"type":"assistant/chunk","seq":140,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":141,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} -{"type":"assistant/chunk","seq":142,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":143,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correct"}}} -{"type":"assistant/chunk","seq":144,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":145,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":146,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":147,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":148,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":149,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":150,"time":1784451785260,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":151,"time":1784451785265,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} -{"type":"assistant/chunk","seq":152,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":153,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":154,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":155,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":156,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":157,"time":1784451785283,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":158,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":159,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":160,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":161,"time":1784451785308,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"MARMALADE\" which is correct. Now I need to reply with PARENT_DONE."}}}} -{"type":"assistant/chunk","seq":162,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":163,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":87,"outputTokens":30,"cacheReadTokens":3712,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":164,"time":1784451785309,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":165,"time":1784451785309,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"MARMALADE\" which is correct. Now I need to reply with PARENT_DONE."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":87,"outputTokens":30,"cacheReadTokens":3712,"reasoningTokens":25}},"sourceEventSeqs":[130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} -{"type":"step/end","seq":166,"time":1784451785309,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":167,"time":1784451785310,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"/tmp/acp-snap-cwd-0HLtcD","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352134840,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352135465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352135621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352135654,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783352135655,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352135682,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":17,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":18,"time":1783352135683,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":19,"time":1783352135712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":20,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":21,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":22,"time":1783352135713,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":23,"time":1783352135739,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":24,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":25,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":26,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":27,"time":1783352135740,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":28,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":29,"time":1783352135770,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":30,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} +{"type":"assistant/chunk","seq":31,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1783352135773,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1783352135773,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":37,"time":1783352135780,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":38,"time":1783352135780,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":39,"time":1783352135781,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":40,"time":1783352136109,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":41,"time":1783352136109,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":42,"time":1783352136226,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":43,"time":1783352136255,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":44,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":45,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":46,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":47,"time":1783352136256,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":48,"time":1783352136282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":49,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":50,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":51,"time":1783352136283,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":52,"time":1783352136314,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} +{"type":"assistant/chunk","seq":53,"time":1783352136314,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":54,"time":1783352136341,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":55,"time":1783352136366,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":56,"time":1783352136367,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":57,"time":1783352136394,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":58,"time":1783352136395,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":59,"time":1783352136395,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":60,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":61,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":62,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":63,"time":1783352136423,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":64,"time":1783352136450,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":65,"time":1783352136451,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":66,"time":1783352136478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":67,"time":1783352136478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":68,"time":1783352136508,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":69,"time":1783352136535,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":70,"time":1783352136535,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" able"}}} +{"type":"assistant/chunk","seq":71,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":72,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}} +{"type":"assistant/chunk","seq":73,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":74,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":75,"time":1783352136563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":76,"time":1783352136591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":77,"time":1783352136591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":78,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":79,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":80,"time":1783352136592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" MAR"}}} +{"type":"assistant/chunk","seq":81,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":82,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":83,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":84,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1783352136620,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":86,"time":1783352136648,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":87,"time":1783352136677,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":88,"time":1783352136677,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":89,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":90,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":91,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":92,"time":1783352136678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":93,"time":1783352136705,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":94,"time":1783352136706,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":95,"time":1783352136706,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} +{"type":"assistant/chunk","seq":96,"time":1783352136732,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":97,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":98,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":99,"time":1783352136733,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":100,"time":1783352136819,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":101,"time":1783352136819,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":102,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":103,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":105,"time":1783352136847,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1783352136876,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":107,"time":1783352136877,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":108,"time":1783352136877,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":109,"time":1783352136903,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":110,"time":1783352136903,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":111,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":112,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":113,"time":1783352136904,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1783352136960,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":115,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":117,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":118,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":119,"time":1783352136961,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":120,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":122,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":123,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":124,"time":1783352136987,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":125,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":126,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":127,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":128,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":129,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":130,"time":1783352137015,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":131,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":132,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":133,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":134,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":135,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":136,"time":1783352137043,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":137,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":138,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":139,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":140,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":141,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":142,"time":1783352137071,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":143,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":144,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":145,"time":1783352137099,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":146,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."}}}} +{"type":"assistant/chunk","seq":147,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":148,"time":1783352137158,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}}}} +{"type":"assistant/chunk","seq":149,"time":1783352137159,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":150,"time":1783352137159,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."},{"type":"tool-call","id":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":158,"outputTokens":147,"cacheReadTokens":2816,"reasoningTokens":59}},"sourceEventSeqs":[40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149],"surfaceOp":"append"} +{"type":"tool/call","seq":151,"time":1783352137159,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":152,"time":1783352138315,"data":{"turn":2,"step":1,"callId":"call_00_sAtKUseRzHRBvL4CF7XF1334","content":[{"type":"text","text":"MARMALADE"}],"isError":false},"sourceEventSeqs":[151],"surfaceOp":"append"} +{"type":"step/end","seq":153,"time":1783352138316,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":154,"time":1783352138317,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":155,"time":1783352138956,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":156,"time":1783352138956,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":157,"time":1783352139100,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":158,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} +{"type":"assistant/chunk","seq":159,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":160,"time":1783352139128,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" agent"}}} +{"type":"assistant/chunk","seq":161,"time":1783352139156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correctly"}}} +{"type":"assistant/chunk","seq":162,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":163,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":164,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":165,"time":1783352139157,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":166,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":167,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":168,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":169,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":170,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":171,"time":1783352139186,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":172,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":173,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":174,"time":1783352139215,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":175,"time":1783352139216,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":176,"time":1783352139256,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":177,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":178,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":179,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":180,"time":1783352139257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":181,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":182,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":183,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":184,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":185,"time":1783352139273,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":186,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":187,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":188,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":189,"time":1783352139274,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":190,"time":1783352139274,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":65,"outputTokens":30,"cacheReadTokens":3072,"reasoningTokens":25}},"sourceEventSeqs":[155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} +{"type":"step/end","seq":191,"time":1783352139274,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":192,"time":1783352139274,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl index e4f4298b50..e2941dd851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl @@ -6,19 +6,23 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fact"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} @@ -32,18 +36,30 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ask"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" able"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" answer"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} @@ -53,32 +69,14 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" task"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3wP4hrLZZQgqILQi2ZXU3942","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correct"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} @@ -86,6 +84,33 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correctly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_D"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index c82688afea..4c8d25ad82 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"f117c899-e0b7-4756-baef-ca24df6c4401","createdAt":1784451789830,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","parentSession":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451789831,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451789831,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451789831,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451789832,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451796262,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451796262,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451796377,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1784451796414,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1784451796437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1784451796437,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":17,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":18,"time":1784451796438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":19,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":20,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":21,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":22,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":23,"time":1784451796464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":26,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":27,"time":1784451796498,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} -{"type":"assistant/chunk","seq":28,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":30,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":31,"time":1784451796499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1784451796500,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1784451796500,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1784451796500,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352145224,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352145224,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352145820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352145821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352145985,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":8,"time":1783352146042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352146043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":17,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":18,"time":1783352146071,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":19,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":22,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":23,"time":1783352146100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":26,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":27,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":28,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":31,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783352146130,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783352146130,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783352146130,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index 59b2ee2956..4bc55d2910 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,77 +1,79 @@ -{"type":"session","version":0,"id":"001332d1-d501-4546-bf82-13e58b28b06b","createdAt":1784451798519,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","parentSession":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","seedLength":33,"delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451785951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451785952,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451785955,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451785955,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451787067,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451787068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451787176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451787205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} -{"type":"assistant/chunk","seq":13,"time":1784451787223,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":14,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":15,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1784451787284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":17,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":18,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":19,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} -{"type":"assistant/chunk","seq":21,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":22,"time":1784451787313,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":23,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451787338,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":26,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":29,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451787343,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451787343,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451787343,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1784451798520,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":34,"time":1784451798520,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":35,"time":1784451798520,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":36,"time":1784451798520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":37,"time":1784451800033,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":38,"time":1784451800033,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":39,"time":1784451800128,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":40,"time":1784451800164,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":41,"time":1784451800165,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} -{"type":"assistant/chunk","seq":42,"time":1784451800165,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":43,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":44,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} -{"type":"assistant/chunk","seq":45,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":46,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":47,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":48,"time":1784451800187,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":49,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":50,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} -{"type":"assistant/chunk","seq":51,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} -{"type":"assistant/chunk","seq":52,"time":1784451800219,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":53,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":54,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":55,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":56,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":57,"time":1784451800241,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":58,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":59,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":60,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":61,"time":1784451800271,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} -{"type":"assistant/chunk","seq":62,"time":1784451800298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} -{"type":"assistant/chunk","seq":63,"time":1784451800298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} -{"type":"assistant/chunk","seq":64,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":65,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":66,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} -{"type":"assistant/chunk","seq":67,"time":1784451800299,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} -{"type":"assistant/chunk","seq":68,"time":1784451800323,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} -{"type":"assistant/chunk","seq":69,"time":1784451800329,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword mentioned earlier in the conversation. The codeword is SAFFRON."}}}} -{"type":"assistant/chunk","seq":70,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":71,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2050,"outputTokens":31,"cacheReadTokens":1280,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":72,"time":1784451800330,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":73,"time":1784451800330,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword mentioned earlier in the conversation. The codeword is SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2050,"outputTokens":31,"cacheReadTokens":1280,"reasoningTokens":27}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72],"surfaceOp":"append"} -{"type":"step/end","seq":74,"time":1784451800330,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":75,"time":1784451800330,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":31,"delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352142835,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352143493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352143494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352143652,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1783352143678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":16,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":17,"time":1783352143707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":21,"time":1783352143736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":22,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":24,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} +{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1783352143771,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1783352143771,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":30,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":31,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":32,"time":1783352147509,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":33,"time":1783352147509,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":34,"time":1783352147509,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":35,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":37,"time":1783352148019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":38,"time":1783352148048,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":39,"time":1783352148049,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":40,"time":1783352148049,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":41,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":42,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" recall"}}} +{"type":"assistant/chunk","seq":43,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":44,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":45,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":47,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":48,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":49,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":50,"time":1783352148106,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} +{"type":"assistant/chunk","seq":51,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":52,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":53,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":54,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":55,"time":1783352148141,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":56,"time":1783352148167,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":57,"time":1783352148196,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":58,"time":1783352148227,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":59,"time":1783352148227,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":60,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":61,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":62,"time":1783352148257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":63,"time":1783352148284,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} +{"type":"assistant/chunk","seq":64,"time":1783352148285,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":65,"time":1783352148312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":66,"time":1783352148312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":67,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} +{"type":"assistant/chunk","seq":69,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} +{"type":"assistant/chunk","seq":70,"time":1783352148344,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} +{"type":"assistant/chunk","seq":71,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":72,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":75,"time":1783352148345,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74],"surfaceOp":"append"} +{"type":"step/end","seq":76,"time":1783352148345,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":77,"time":1783352148345,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index e5029f4248..b977a17e16 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,334 +1,288 @@ -{"type":"session","version":0,"id":"91b46b45-a870-42dc-9314-be4ceeb9c3f3","createdAt":1784451785949,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-vBPxcm","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784451785951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451785952,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451785955,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451785955,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451787067,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451787068,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451787176,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451787205,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451787206,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} -{"type":"assistant/chunk","seq":11,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1784451787207,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" fact"}}} -{"type":"assistant/chunk","seq":13,"time":1784451787223,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":14,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":15,"time":1784451787267,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1784451787284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":17,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":18,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":19,"time":1784451787285,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":20,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} -{"type":"assistant/chunk","seq":21,"time":1784451787312,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} -{"type":"assistant/chunk","seq":22,"time":1784451787313,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} -{"type":"assistant/chunk","seq":23,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451787337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451787338,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} -{"type":"assistant/chunk","seq":26,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":29,"time":1784451787342,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451787343,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a fact and reply with a single word. No tools needed."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3567,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451787343,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451787343,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1784451787362,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":34,"time":1784451787362,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":35,"time":1784451787362,"data":{"turn":2,"step":1}} -{"type":"assistant/chunk","seq":36,"time":1784451788687,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":37,"time":1784451788687,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":38,"time":1784451788784,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":39,"time":1784451788814,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":40,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":41,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":42,"time":1784451788815,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":43,"time":1784451788841,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} -{"type":"assistant/chunk","seq":44,"time":1784451788841,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} -{"type":"assistant/chunk","seq":45,"time":1784451788867,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} -{"type":"assistant/chunk","seq":46,"time":1784451788867,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} -{"type":"assistant/chunk","seq":47,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":48,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":49,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":50,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} -{"type":"assistant/chunk","seq":51,"time":1784451788899,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":52,"time":1784451788900,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":53,"time":1784451788900,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":54,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":55,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":56,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":57,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":58,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} -{"type":"assistant/chunk","seq":59,"time":1784451788927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":60,"time":1784451788953,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} -{"type":"assistant/chunk","seq":61,"time":1784451788953,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":62,"time":1784451788954,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":63,"time":1784451788954,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} -{"type":"assistant/chunk","seq":64,"time":1784451788977,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":65,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":66,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} -{"type":"assistant/chunk","seq":67,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":68,"time":1784451789011,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":69,"time":1784451789012,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":70,"time":1784451789012,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":71,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} -{"type":"assistant/chunk","seq":72,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":73,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":74,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":75,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":76,"time":1784451789040,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":77,"time":1784451789060,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} -{"type":"assistant/chunk","seq":78,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":79,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":80,"time":1784451789061,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":81,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":82,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":83,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":84,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":85,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":86,"time":1784451789097,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":87,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":88,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":89,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":90,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":91,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":92,"time":1784451789119,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} -{"type":"assistant/chunk","seq":93,"time":1784451789144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} -{"type":"assistant/chunk","seq":94,"time":1784451789144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} -{"type":"assistant/chunk","seq":95,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":96,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} -{"type":"assistant/chunk","seq":97,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} -{"type":"assistant/chunk","seq":98,"time":1784451789145,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":99,"time":1784451789173,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":100,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} -{"type":"assistant/chunk","seq":101,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":102,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":103,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} -{"type":"assistant/chunk","seq":104,"time":1784451789174,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":105,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":106,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"What"}}} -{"type":"assistant/chunk","seq":107,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":108,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":109,"time":1784451789202,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":110,"time":1784451789203,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":111,"time":1784451789229,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":112,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":113,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} -{"type":"assistant/chunk","seq":114,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} -{"type":"assistant/chunk","seq":115,"time":1784451789230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":116,"time":1784451789231,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":117,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":118,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?"}}} -{"type":"assistant/chunk","seq":119,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} -{"type":"assistant/chunk","seq":120,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":121,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":122,"time":1784451789258,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":123,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} -{"type":"assistant/chunk","seq":124,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":125,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":126,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":127,"time":1784451789286,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":128,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} -{"type":"assistant/chunk","seq":129,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":130,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":131,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} -{"type":"assistant/chunk","seq":132,"time":1784451789312,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} -{"type":"assistant/chunk","seq":133,"time":1784451789313,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} -{"type":"assistant/chunk","seq":134,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":135,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":136,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":137,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} -{"type":"assistant/chunk","seq":138,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":139,"time":1784451789348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":140,"time":1784451789364,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":141,"time":1784451789365,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":142,"time":1784451789405,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'.\n\n"}}} -{"type":"assistant/chunk","seq":143,"time":1784451789405,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":144,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":145,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":146,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":147,"time":1784451789406,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}} -{"type":"assistant/chunk","seq":148,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} -{"type":"assistant/chunk","seq":149,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":150,"time":1784451789425,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":151,"time":1784451789509,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":152,"time":1784451789509,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":153,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":154,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":155,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":156,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":157,"time":1784451789537,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":158,"time":1784451789557,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":159,"time":1784451789557,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":160,"time":1784451789586,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":161,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":162,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":163,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" only"}}} -{"type":"assistant/chunk","seq":164,"time":1784451789620,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":165,"time":1784451789641,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":166,"time":1784451789641,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":167,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":168,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":169,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":170,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":171,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":172,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":173,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":174,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":175,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":176,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":177,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":178,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":179,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":180,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":181,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":182,"time":1784451789761,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":183,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":184,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":185,"time":1784451789766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":186,"time":1784451789828,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool (fresh child) with the prompt: 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool (forked child that inherits this conversation) with the prompt: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with step 1."}}}} -{"type":"assistant/chunk","seq":187,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":188,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":229,"outputTokens":189,"cacheReadTokens":3456,"reasoningTokens":114}}}} -{"type":"assistant/chunk","seq":189,"time":1784451789829,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":190,"time":1784451789829,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool (fresh child) with the prompt: 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool (forked child that inherits this conversation) with the prompt: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":229,"outputTokens":189,"cacheReadTokens":3456,"reasoningTokens":114}},"sourceEventSeqs":[36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} -{"type":"tool/call","seq":191,"time":1784451789829,"data":{"turn":2,"step":1,"callId":"call_00_fOmtdGKlmFUAPjPULOUC7301","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":192,"time":1784451796518,"data":{"turn":2,"step":1,"callId":"call_00_fOmtdGKlmFUAPjPULOUC7301","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[191],"surfaceOp":"append"} -{"type":"step/end","seq":193,"time":1784451796519,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":194,"time":1784451796519,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":195,"time":1784451797797,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":196,"time":1784451797797,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":197,"time":1784451797899,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":198,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":199,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":200,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":201,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":202,"time":1784451797911,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":203,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":204,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":205,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":206,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":207,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":208,"time":1784451797947,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":209,"time":1784451797972,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":210,"time":1784451797972,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":211,"time":1784451797997,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":212,"time":1784451798035,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":213,"time":1784451798035,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":214,"time":1784451798036,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":215,"time":1784451798036,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":216,"time":1784451798058,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} -{"type":"assistant/chunk","seq":217,"time":1784451798058,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} -{"type":"assistant/chunk","seq":218,"time":1784451798090,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":219,"time":1784451798090,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} -{"type":"assistant/chunk","seq":220,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} -{"type":"assistant/chunk","seq":221,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} -{"type":"assistant/chunk","seq":222,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} -{"type":"assistant/chunk","seq":223,"time":1784451798091,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":224,"time":1784451798192,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":225,"time":1784451798192,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":226,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":227,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":228,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":229,"time":1784451798225,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":230,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":231,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":232,"time":1784451798257,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"Recall"}}} -{"type":"assistant/chunk","seq":233,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":234,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":235,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":236,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":237,"time":1784451798290,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":238,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":239,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":240,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":241,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":242,"time":1784451798330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":243,"time":1784451798331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":244,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":245,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"What"}}} -{"type":"assistant/chunk","seq":246,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" is"}}} -{"type":"assistant/chunk","seq":247,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":248,"time":1784451798364,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" project"}}} -{"type":"assistant/chunk","seq":249,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" cod"}}} -{"type":"assistant/chunk","seq":250,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ew"}}} -{"type":"assistant/chunk","seq":251,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"ord"}}} -{"type":"assistant/chunk","seq":252,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" mentioned"}}} -{"type":"assistant/chunk","seq":253,"time":1784451798378,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" earlier"}}} -{"type":"assistant/chunk","seq":254,"time":1784451798409,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" in"}}} -{"type":"assistant/chunk","seq":255,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" this"}}} -{"type":"assistant/chunk","seq":256,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" conversation"}}} -{"type":"assistant/chunk","seq":257,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"?"}}} -{"type":"assistant/chunk","seq":258,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" Reply"}}} -{"type":"assistant/chunk","seq":259,"time":1784451798410,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":260,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":261,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" that"}}} -{"type":"assistant/chunk","seq":262,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" one"}}} -{"type":"assistant/chunk","seq":263,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":264,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":265,"time":1784451798426,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":266,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":267,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":268,"time":1784451798454,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":269,"time":1784451798476,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":270,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork to ask about the project codeword."}}}} -{"type":"assistant/chunk","seq":271,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":272,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":116,"cacheReadTokens":3840,"reasoningTokens":28}}}} -{"type":"assistant/chunk","seq":273,"time":1784451798518,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":274,"time":1784451798518,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork to ask about the project codeword."},{"type":"tool-call","id":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":49,"outputTokens":116,"cacheReadTokens":3840,"reasoningTokens":28}},"sourceEventSeqs":[195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273],"surfaceOp":"append"} -{"type":"tool/call","seq":275,"time":1784451798518,"data":{"turn":2,"step":2,"callId":"call_00_4obRCMnU95bJeDWflv6h9606","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} -{"type":"tool/result","seq":276,"time":1784451800338,"data":{"turn":2,"step":2,"callId":"call_00_4obRCMnU95bJeDWflv6h9606","content":[{"type":"text","text":"SAFFRON"}],"isError":false},"sourceEventSeqs":[275],"surfaceOp":"append"} -{"type":"step/end","seq":277,"time":1784451800339,"data":{"turn":2,"step":2}} -{"type":"step/start","seq":278,"time":1784451800340,"data":{"turn":2,"step":3}} -{"type":"assistant/chunk","seq":279,"time":1784451801612,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":280,"time":1784451801612,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} -{"type":"assistant/chunk","seq":281,"time":1784451801773,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":282,"time":1784451801804,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} -{"type":"assistant/chunk","seq":283,"time":1784451801805,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":284,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":285,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":286,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":287,"time":1784451801830,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} -{"type":"assistant/chunk","seq":288,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":289,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":290,"time":1784451801857,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":291,"time":1784451801888,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} -{"type":"assistant/chunk","seq":292,"time":1784451801889,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} -{"type":"assistant/chunk","seq":293,"time":1784451801889,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} -{"type":"assistant/chunk","seq":294,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":295,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":296,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":297,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":298,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":299,"time":1784451801919,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Sub"}}} -{"type":"assistant/chunk","seq":300,"time":1784451801939,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":301,"time":1784451801939,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} -{"type":"assistant/chunk","seq":302,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} -{"type":"assistant/chunk","seq":303,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":304,"time":1784451801967,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"inher"}}} -{"type":"assistant/chunk","seq":305,"time":1784451801993,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"iting"}}} -{"type":"assistant/chunk","seq":306,"time":1784451802026,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} -{"type":"assistant/chunk","seq":307,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} -{"type":"assistant/chunk","seq":308,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} -{"type":"assistant/chunk","seq":309,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} -{"type":"assistant/chunk","seq":310,"time":1784451802027,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} -{"type":"assistant/chunk","seq":311,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\n\n"}}} -{"type":"assistant/chunk","seq":312,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} -{"type":"assistant/chunk","seq":313,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":314,"time":1784451802055,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":315,"time":1784451802086,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":316,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} -{"type":"assistant/chunk","seq":317,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":318,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":319,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":320,"time":1784451802087,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":321,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":322,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":323,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":324,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":325,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":326,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First subagent (fresh): ALPHA\n2. Subagent_fork (inheriting conversation): SAFFRON\n\nNow I reply with PARENT_DONE."}}}} -{"type":"assistant/chunk","seq":327,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":328,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":46,"cacheReadTokens":3968,"reasoningTokens":41}}}} -{"type":"assistant/chunk","seq":329,"time":1784451802126,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":330,"time":1784451802126,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents returned:\n1. First subagent (fresh): ALPHA\n2. Subagent_fork (inheriting conversation): SAFFRON\n\nNow I reply with PARENT_DONE."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":52,"outputTokens":46,"cacheReadTokens":3968,"reasoningTokens":41}},"sourceEventSeqs":[279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329],"surfaceOp":"append"} -{"type":"step/end","seq":331,"time":1784451802126,"data":{"turn":2,"step":3}} -{"type":"turn/end","seq":332,"time":1784451802126,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"/tmp/acp-snap-cwd-i43JSF","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352142835,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352143493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352143494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352143621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352143652,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783352143653,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1783352143678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":16,"time":1783352143679,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":17,"time":1783352143707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783352143708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":21,"time":1783352143736,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":22,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1783352143766,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":24,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} +{"type":"assistant/chunk","seq":25,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":28,"time":1783352143771,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":1783352143771,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":30,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":31,"time":1783352143779,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":32,"time":1783352143779,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":33,"time":1783352143779,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":34,"time":1783352144351,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":35,"time":1783352144352,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":36,"time":1783352144477,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":37,"time":1783352144504,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":38,"time":1783352144533,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" these"}}} +{"type":"assistant/chunk","seq":39,"time":1783352144562,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":40,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} +{"type":"assistant/chunk","seq":41,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} +{"type":"assistant/chunk","seq":42,"time":1783352144563,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":43,"time":1783352144591,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":44,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":45,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":46,"time":1783352144592,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":47,"time":1783352144621,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} +{"type":"assistant/chunk","seq":48,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":49,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} +{"type":"assistant/chunk","seq":50,"time":1783352144650,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":51,"time":1783352144678,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":52,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":53,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":54,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":56,"time":1783352144679,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":57,"time":1783352144707,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":58,"time":1783352144708,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":59,"time":1783352144737,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} +{"type":"assistant/chunk","seq":60,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":61,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":62,"time":1783352144738,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":63,"time":1783352144765,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":64,"time":1783352144794,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":65,"time":1783352144794,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":66,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":67,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":68,"time":1783352144795,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":69,"time":1783352144824,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":70,"time":1783352144892,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":71,"time":1783352144892,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":72,"time":1783352144931,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":73,"time":1783352144932,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1783352144932,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":75,"time":1783352145000,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":77,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783352145001,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":80,"time":1783352145012,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":81,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":82,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" only"}}} +{"type":"assistant/chunk","seq":83,"time":1783352145013,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1783352145047,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":85,"time":1783352145047,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783352145073,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":87,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":88,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783352145074,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":90,"time":1783352145104,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1783352145104,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":92,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":95,"time":1783352145105,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":96,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":97,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":98,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":99,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":100,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":101,"time":1783352145131,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":102,"time":1783352145160,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":103,"time":1783352145161,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783352145161,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":105,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."}}}} +{"type":"assistant/chunk","seq":106,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":107,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":108,"time":1783352145221,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":109,"time":1783352145221,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."},{"type":"tool-call","id":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":185,"outputTokens":110,"cacheReadTokens":2816,"reasoningTokens":35}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"tool/call","seq":110,"time":1783352145222,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","name":"subagent","arguments":"{\"description\": \"Reply ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":111,"time":1783352146133,"data":{"turn":2,"step":1,"callId":"call_00_YvHr2bGomk5HhpgDTvE81896","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[110],"surfaceOp":"append"} +{"type":"step/end","seq":112,"time":1783352146134,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":113,"time":1783352146134,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":114,"time":1783352146748,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":115,"time":1783352146748,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":116,"time":1783352146837,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":117,"time":1783352146865,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":118,"time":1783352146865,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":119,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":120,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":122,"time":1783352146866,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":123,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":124,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":125,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":126,"time":1783352146897,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":127,"time":1783352146898,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":128,"time":1783352146898,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":129,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":130,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":131,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":132,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":133,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":134,"time":1783352146923,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":135,"time":1783352146951,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":136,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":137,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} +{"type":"assistant/chunk","seq":138,"time":1783352146952,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} +{"type":"assistant/chunk","seq":139,"time":1783352146979,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":140,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":141,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":142,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":143,"time":1783352146980,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":144,"time":1783352147009,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":145,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":146,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":147,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":148,"time":1783352147010,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":149,"time":1783352147037,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":150,"time":1783352147037,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":151,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":152,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":153,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":154,"time":1783352147038,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":155,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":156,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":157,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":158,"time":1783352147156,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":160,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":161,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":162,"time":1783352147186,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":163,"time":1783352147214,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":164,"time":1783352147242,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":165,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":166,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":167,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":168,"time":1783352147243,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":169,"time":1783352147303,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":170,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":171,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":172,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":173,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":174,"time":1783352147304,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":175,"time":1783352147330,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":176,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":177,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":178,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":179,"time":1783352147331,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":180,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":181,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":182,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":183,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":184,"time":1783352147357,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":185,"time":1783352147358,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":186,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":187,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":188,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":189,"time":1783352147385,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":190,"time":1783352147386,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":191,"time":1783352147386,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":192,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":193,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":194,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":195,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":196,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":197,"time":1783352147414,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":198,"time":1783352147442,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":199,"time":1783352147442,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":200,"time":1783352147443,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":201,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."}}}} +{"type":"assistant/chunk","seq":202,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":203,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}}}} +{"type":"assistant/chunk","seq":204,"time":1783352147502,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":205,"time":1783352147503,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."},{"type":"tool-call","id":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":54,"outputTokens":128,"cacheReadTokens":3072,"reasoningTokens":40}},"sourceEventSeqs":[114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"tool/call","seq":206,"time":1783352147503,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":207,"time":1783352148348,"data":{"turn":2,"step":2,"callId":"call_00_JSr5rhREq23wSmwSkCP77184","content":[{"type":"text","text":"SAFFRON"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} +{"type":"step/end","seq":208,"time":1783352148348,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":209,"time":1783352148348,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":210,"time":1783352149007,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":211,"time":1783352149008,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":212,"time":1783352149189,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":213,"time":1783352149217,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":214,"time":1783352149217,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":215,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":216,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":217,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":218,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":219,"time":1783352149246,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":220,"time":1783352149273,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fresh"}}} +{"type":"assistant/chunk","seq":221,"time":1783352149274,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":222,"time":1783352149305,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} +{"type":"assistant/chunk","seq":223,"time":1783352149306,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":224,"time":1783352149330,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":225,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":226,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":227,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":228,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":229,"time":1783352149331,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":230,"time":1783352149359,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Second"}}} +{"type":"assistant/chunk","seq":231,"time":1783352149359,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":232,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"fork"}}} +{"type":"assistant/chunk","seq":233,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ed"}}} +{"type":"assistant/chunk","seq":234,"time":1783352149360,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":235,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"):"}}} +{"type":"assistant/chunk","seq":236,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":237,"time":1783352149388,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":238,"time":1783352149416,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":239,"time":1783352149417,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":240,"time":1783352149417,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":241,"time":1783352149445,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":242,"time":1783352149445,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" correctly"}}} +{"type":"assistant/chunk","seq":243,"time":1783352149474,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" inherited"}}} +{"type":"assistant/chunk","seq":244,"time":1783352149503,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":245,"time":1783352149503,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":246,"time":1783352149536,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" context"}}} +{"type":"assistant/chunk","seq":247,"time":1783352149536,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" where"}}} +{"type":"assistant/chunk","seq":248,"time":1783352149559,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":249,"time":1783352149588,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":250,"time":1783352149619,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":251,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":252,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":253,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":254,"time":1783352149650,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":255,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":256,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":257,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":258,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":259,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":260,"time":1783352149677,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":261,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\".\n\n"}}} +{"type":"assistant/chunk","seq":262,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":263,"time":1783352149706,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":264,"time":1783352149707,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":265,"time":1783352149734,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":266,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":267,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":268,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":269,"time":1783352149735,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":270,"time":1783352149762,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":271,"time":1783352149763,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":272,"time":1783352149763,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":273,"time":1783352149791,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":274,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":275,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":276,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":277,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":278,"time":1783352149792,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":279,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":280,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}} +{"type":"assistant/chunk","seq":281,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":282,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}}}} +{"type":"assistant/chunk","seq":283,"time":1783352149821,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":284,"time":1783352149822,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":69,"outputTokens":69,"cacheReadTokens":3200,"reasoningTokens":64}},"sourceEventSeqs":[210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283],"surfaceOp":"append"} +{"type":"step/end","seq":285,"time":1783352149822,"data":{"turn":2,"step":3}} +{"type":"turn/end","seq":286,"time":1783352149822,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index 95a9090889..e5cc8bfa90 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -7,35 +7,36 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fact"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" needed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" these"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" deleg"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ations"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sequentially"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} @@ -45,29 +46,30 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fresh"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} @@ -85,77 +87,6 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"What"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mentioned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" earlier"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"?"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" '"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'.\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" step"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_fOmtdGKlmFUAPjPULOUC7301","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_fOmtdGKlmFUAPjPULOUC7301","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ask"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} @@ -165,8 +96,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_4obRCMnU95bJeDWflv6h9606","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_4obRCMnU95bJeDWflv6h9606","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} @@ -175,38 +106,61 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fresh"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"):"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Sub"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Second"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"inher"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"iting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"fork"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"):"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" correctly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inherited"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" conversation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" context"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" where"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" remember"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\".\n\n"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index 7f7fe92b80..53b59caa4b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"f425022f-47e5-46e7-84f6-c364c7e969d8","createdAt":1784451769872,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","parentSession":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451769874,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451769874,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451769874,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451769875,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451770880,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451770880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451770966,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1784451770999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":17,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":18,"time":1784451771027,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":19,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":20,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":21,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":22,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":23,"time":1784451771060,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":24,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} -{"type":"assistant/chunk","seq":26,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} -{"type":"assistant/chunk","seq":27,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} -{"type":"assistant/chunk","seq":28,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":30,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":31,"time":1784451771087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1784451771087,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3284,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1784451771087,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1784451771087,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352127671,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352127671,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352128125,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352128125,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352128240,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352128281,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":17,"time":1783352128300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":18,"time":1783352128301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":19,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":22,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":23,"time":1783352128332,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1783352128364,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":26,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":27,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":28,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":31,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783352128365,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783352128365,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783352128366,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 4ece8fa0de..412ae705db 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,34 +1,34 @@ -{"type":"session","version":0,"id":"6f1571df-557f-45ff-b434-e13ee06d2b9f","createdAt":1784451773103,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","parentSession":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451773104,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451773104,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451773105,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451773105,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451775865,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451775865,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451775999,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1784451776031,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":14,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":15,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":16,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":17,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":18,"time":1784451776052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":21,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":22,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1784451776078,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":25,"time":1784451776102,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":26,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":83,"outputTokens":21,"cacheReadTokens":3200,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":29,"time":1784451776108,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451776109,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":83,"outputTokens":21,"cacheReadTokens":3200,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451776109,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451776109,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352129663,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352129663,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352130236,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352130236,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352130375,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":17,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":18,"time":1783352130448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783352130484,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":25,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":26,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":27,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":28,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1783352130528,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1783352130528,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1783352130528,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 75a7460776..b632943f89 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,194 +1,208 @@ -{"type":"session","version":0,"id":"23127c8b-3c39-4dca-8cb6-8111f50bd23f","createdAt":1784451767994,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ogFsTm","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784451767996,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451767996,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451768000,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451768000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451769159,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451769159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451769269,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451769299,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451769300,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":11,"time":1784451769301,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1784451769327,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":13,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":14,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":15,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} -{"type":"assistant/chunk","seq":16,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":17,"time":1784451769328,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} -{"type":"assistant/chunk","seq":18,"time":1784451769350,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":19,"time":1784451769381,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} -{"type":"assistant/chunk","seq":20,"time":1784451769408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} -{"type":"assistant/chunk","seq":21,"time":1784451769408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":22,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} -{"type":"assistant/chunk","seq":23,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} -{"type":"assistant/chunk","seq":24,"time":1784451769409,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":25,"time":1784451769464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":26,"time":1784451769464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompts"}}} -{"type":"assistant/chunk","seq":27,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":28,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":29,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":30,"time":1784451769469,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":31,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":32,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":33,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":34,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} -{"type":"assistant/chunk","seq":35,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":36,"time":1784451769505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":37,"time":1784451769563,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1784451769563,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":39,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":40,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":42,"time":1784451769592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":44,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"First"}}} -{"type":"assistant/chunk","seq":46,"time":1784451769620,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" subt"}}} -{"type":"assistant/chunk","seq":47,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"ask"}}} -{"type":"assistant/chunk","seq":48,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" -"}}} -{"type":"assistant/chunk","seq":49,"time":1784451769650,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":50,"time":1784451769670,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":51,"time":1784451769671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":52,"time":1784451769671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":54,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1784451769696,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":56,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":57,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":59,"time":1784451769753,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":60,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":61,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":62,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":63,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":64,"time":1784451769762,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":65,"time":1784451769763,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" AL"}}} -{"type":"assistant/chunk","seq":66,"time":1784451769782,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"P"}}} -{"type":"assistant/chunk","seq":67,"time":1784451769782,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"HA"}}} -{"type":"assistant/chunk","seq":68,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":69,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":70,"time":1784451769783,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":71,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":72,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":73,"time":1784451769803,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":74,"time":1784451769866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time), with specific prompts. Let me start with the first subtask."}}}} -{"type":"assistant/chunk","seq":75,"time":1784451769866,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":76,"time":1784451769867,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3621,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":77,"time":1784451769867,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":78,"time":1784451769870,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time), with specific prompts. Let me start with the first subtask."},{"type":"tool-call","id":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3621,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":32}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"surfaceOp":"append"} -{"type":"tool/call","seq":79,"time":1784451769871,"data":{"turn":1,"step":1,"callId":"call_00_pNGPLxkadUA9vn2Bm42x5565","name":"subagent","arguments":"{\"description\": \"First subtask - ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} -{"type":"tool/result","seq":80,"time":1784451771107,"data":{"turn":1,"step":1,"callId":"call_00_pNGPLxkadUA9vn2Bm42x5565","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[79],"surfaceOp":"append"} -{"type":"step/end","seq":81,"time":1784451771108,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":82,"time":1784451771108,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":83,"time":1784451772258,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":84,"time":1784451772258,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":85,"time":1784451772383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":86,"time":1784451772412,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":87,"time":1784451772439,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":88,"time":1784451772440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":89,"time":1784451772440,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":90,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":91,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":92,"time":1784451772441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":93,"time":1784451772491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":94,"time":1784451772491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":95,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":96,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":97,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":98,"time":1784451772492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":99,"time":1784451772494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":100,"time":1784451772494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":101,"time":1784451772495,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} -{"type":"assistant/chunk","seq":102,"time":1784451772517,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} -{"type":"assistant/chunk","seq":103,"time":1784451772517,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":104,"time":1784451772600,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":105,"time":1784451772600,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":106,"time":1784451772634,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":107,"time":1784451772634,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":109,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":110,"time":1784451772635,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":111,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":112,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"Second"}}} -{"type":"assistant/chunk","seq":113,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" subt"}}} -{"type":"assistant/chunk","seq":114,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ask"}}} -{"type":"assistant/chunk","seq":115,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" -"}}} -{"type":"assistant/chunk","seq":116,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":117,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ETA"}}} -{"type":"assistant/chunk","seq":118,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":119,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":120,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":121,"time":1784451773100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":122,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":123,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":124,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":125,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":127,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":128,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":129,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":130,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":131,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" B"}}} -{"type":"assistant/chunk","seq":132,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"ETA"}}} -{"type":"assistant/chunk","seq":133,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":134,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":135,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":136,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":137,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":138,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":139,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to run the second subtask."}}}} -{"type":"assistant/chunk","seq":140,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":141,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":95,"cacheReadTokens":3712,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":142,"time":1784451773101,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":143,"time":1784451773102,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to run the second subtask."},{"type":"tool-call","id":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":95,"cacheReadTokens":3712,"reasoningTokens":20}},"sourceEventSeqs":[83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} -{"type":"tool/call","seq":144,"time":1784451773102,"data":{"turn":1,"step":2,"callId":"call_00_xg61TI40vMlqTuVIpJWj8575","name":"subagent","arguments":"{\"description\": \"Second subtask - BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} -{"type":"tool/result","seq":145,"time":1784451776122,"data":{"turn":1,"step":2,"callId":"call_00_xg61TI40vMlqTuVIpJWj8575","content":[{"type":"text","text":"BETA"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} -{"type":"step/end","seq":146,"time":1784451776122,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":147,"time":1784451776122,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":148,"time":1784451777273,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":149,"time":1784451777273,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} -{"type":"assistant/chunk","seq":150,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":151,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} -{"type":"assistant/chunk","seq":152,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} -{"type":"assistant/chunk","seq":153,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":154,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":155,"time":1784451777404,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} -{"type":"assistant/chunk","seq":156,"time":1784451777407,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":157,"time":1784451777407,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":158,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":159,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} -{"type":"assistant/chunk","seq":160,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} -{"type":"assistant/chunk","seq":161,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} -{"type":"assistant/chunk","seq":162,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} -{"type":"assistant/chunk","seq":163,"time":1784451777439,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":164,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":165,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} -{"type":"assistant/chunk","seq":166,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} -{"type":"assistant/chunk","seq":167,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":168,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":169,"time":1784451777470,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":170,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":171,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":172,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":173,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":174,"time":1784451777490,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":175,"time":1784451777515,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":176,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":177,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":178,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":179,"time":1784451777546,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":180,"time":1784451777547,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":181,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":182,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":183,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":184,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":185,"time":1784451777578,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":186,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. First returned \"ALPHA\", second returned \"BETA\". Now I need to reply with exactly \"PARENT_DONE\"."}}}} -{"type":"assistant/chunk","seq":187,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":188,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":14,"outputTokens":37,"cacheReadTokens":3840,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":189,"time":1784451777586,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":190,"time":1784451777586,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. First returned \"ALPHA\", second returned \"BETA\". Now I need to reply with exactly \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":14,"outputTokens":37,"cacheReadTokens":3840,"reasoningTokens":32}},"sourceEventSeqs":[148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} -{"type":"step/end","seq":191,"time":1784451777586,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":192,"time":1784451777586,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"/tmp/acp-snap-cwd-28z5Of","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352126252,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352126253,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352126729,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352126729,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352126848,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352126877,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783352126878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783352126907,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":13,"time":1783352126907,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":14,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":15,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":16,"time":1783352126908,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":17,"time":1783352126909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} +{"type":"assistant/chunk","seq":18,"time":1783352126933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":19,"time":1783352126963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":20,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":21,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":22,"time":1783352126992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":23,"time":1783352126993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")."}}} +{"type":"assistant/chunk","seq":24,"time":1783352126993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":25,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":26,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":27,"time":1783352127023,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":28,"time":1783352127052,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":29,"time":1783352127053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":31,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":32,"time":1783352127080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":33,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":34,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":35,"time":1783352127081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":36,"time":1783352127110,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":37,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":38,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":39,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":40,"time":1783352127139,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":41,"time":1783352127172,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":42,"time":1783352127197,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":43,"time":1783352127198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":44,"time":1783352127198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":45,"time":1783352127227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":46,"time":1783352127227,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":47,"time":1783352127228,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":48,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":49,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":50,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":51,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":52,"time":1783352127257,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":53,"time":1783352127258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":54,"time":1783352127343,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":55,"time":1783352127344,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":56,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":57,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":59,"time":1783352127374,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783352127401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":61,"time":1783352127401,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783352127402,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"Return"}}} +{"type":"assistant/chunk","seq":63,"time":1783352127430,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":64,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":65,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":66,"time":1783352127431,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" only"}}} +{"type":"assistant/chunk","seq":67,"time":1783352127460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":68,"time":1783352127486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":69,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":71,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":72,"time":1783352127487,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1783352127515,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":74,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":76,"time":1783352127516,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":77,"time":1783352127545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":78,"time":1783352127545,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":79,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":80,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":81,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":82,"time":1783352127546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":83,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":84,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":85,"time":1783352127577,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":86,"time":1783352127578,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":87,"time":1783352127578,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":88,"time":1783352127605,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":89,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":90,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":91,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}}}} +{"type":"assistant/chunk","seq":92,"time":1783352127665,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":93,"time":1783352127668,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."},{"type":"tool-call","id":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2938,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":49}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} +{"type":"tool/call","seq":94,"time":1783352127668,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","name":"subagent","arguments":"{\"description\": \"Return ALPHA only\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":95,"time":1783352128371,"data":{"turn":1,"step":1,"callId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[94],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1783352128371,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":97,"time":1783352128372,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":98,"time":1783352129034,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":99,"time":1783352129034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} +{"type":"assistant/chunk","seq":100,"time":1783352129152,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":101,"time":1783352129166,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":102,"time":1783352129167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":103,"time":1783352129167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":104,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":105,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":106,"time":1783352129196,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":107,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":108,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":109,"time":1783352129197,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":110,"time":1783352129224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":111,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":112,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":113,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":114,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":115,"time":1783352129254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":116,"time":1783352129255,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":117,"time":1783352129282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":118,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":119,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":120,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":121,"time":1783352129283,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":122,"time":1783352129371,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":123,"time":1783352129371,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":124,"time":1783352129399,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":125,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":127,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783352129400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":129,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":130,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"Return"}}} +{"type":"assistant/chunk","seq":131,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":132,"time":1783352129428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":133,"time":1783352129457,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" only"}}} +{"type":"assistant/chunk","seq":134,"time":1783352129457,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":135,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":136,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":137,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":138,"time":1783352129485,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":139,"time":1783352129515,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":141,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":142,"time":1783352129516,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":143,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":144,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":145,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":146,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":147,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":148,"time":1783352129543,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":149,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":150,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":151,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":152,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":153,"time":1783352129574,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1783352129603,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":155,"time":1783352129660,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}}} +{"type":"assistant/chunk","seq":156,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":157,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":158,"time":1783352129661,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":159,"time":1783352129661,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."},{"type":"tool-call","id":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":133,"outputTokens":96,"cacheReadTokens":2944,"reasoningTokens":23}},"sourceEventSeqs":[98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} +{"type":"tool/call","seq":160,"time":1783352129661,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","name":"subagent","arguments":"{\"description\": \"Return BETA only\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} +{"type":"tool/result","seq":161,"time":1783352130531,"data":{"turn":1,"step":2,"callId":"call_00_FudNKuJ0fchSptGy3Scw1411","content":[{"type":"text","text":"BETA"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1783352130531,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":163,"time":1783352130532,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":164,"time":1783352130930,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":165,"time":1783352130930,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":166,"time":1783352131045,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":167,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":168,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":169,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":170,"time":1783352131073,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":171,"time":1783352131096,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":172,"time":1783352131097,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":173,"time":1783352131128,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":174,"time":1783352131128,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":175,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":176,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":177,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":178,"time":1783352131129,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":179,"time":1783352131157,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":180,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":181,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":182,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":183,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":184,"time":1783352131158,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":185,"time":1783352131185,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":186,"time":1783352131185,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":187,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":188,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":189,"time":1783352131186,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":190,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":191,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":192,"time":1783352131213,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":193,"time":1783352131214,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":194,"time":1783352131214,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":195,"time":1783352131241,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":196,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":197,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":198,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":199,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":200,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":201,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":202,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":203,"time":1783352131242,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":204,"time":1783352131243,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":115,"outputTokens":35,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203],"surfaceOp":"append"} +{"type":"step/end","seq":205,"time":1783352131243,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":206,"time":1783352131243,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index 487692bb46..bd4fb81d4a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -18,24 +18,40 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"),"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompts"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_pNGPLxkadUA9vn2Bm42x5565","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"First subtask - ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_pNGPLxkadUA9vn2Bm42x5565","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} @@ -46,42 +62,44 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xg61TI40vMlqTuVIpJWj8575","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Second subtask - BETA","prompt":"Reply with exactly the word BETA and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_xg61TI40vMlqTuVIpJWj8575","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return BETA only","prompt":"Reply with exactly the word BETA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" First"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"B"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 5afa333183..3d0bf1e801 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,34 +1,34 @@ -{"type":"session","version":0,"id":"aefc3a97-9b46-42d3-993c-7f7c19f9e327","createdAt":1784451764214,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C","parentSession":"5ab41657-0a0f-4317-88fe-451c5197cdb4","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451764215,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451764216,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451764216,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451764216,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451765645,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451765645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451765790,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1784451765802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1784451765826,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1784451765827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} -{"type":"assistant/chunk","seq":15,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":16,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":17,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":18,"time":1784451765829,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":19,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":20,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":21,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} -{"type":"assistant/chunk","seq":24,"time":1784451765854,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} -{"type":"assistant/chunk","seq":25,"time":1784451765887,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":26,"time":1784451765891,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":27,"time":1784451765891,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1784451765892,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3286,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":29,"time":1784451765892,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1784451765892,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"CHILD_OK\" and nothing else."},{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3286,"outputTokens":21,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1784451765893,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":32,"time":1784451765893,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"/tmp/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352120856,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352120856,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352121437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352121438,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352121635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352121664,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352121691,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} +{"type":"assistant/chunk","seq":16,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1783352121720,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":20,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":21,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1783352121747,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} +{"type":"assistant/chunk","seq":24,"time":1783352121748,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":25,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":26,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} +{"type":"assistant/chunk","seq":27,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":29,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1783352121777,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1783352121778,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1783352121778,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index c46bf5237b..655fb4f2a0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,127 +1,160 @@ -{"type":"session","version":0,"id":"5ab41657-0a0f-4317-88fe-451c5197cdb4","createdAt":1784451761926,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-ErhW9C","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784451761930,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451761930,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451761932,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451761932,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451763352,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451763353,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451763485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":11,"time":1784451763528,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":13,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":14,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":15,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":16,"time":1784451763542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} -{"type":"assistant/chunk","seq":17,"time":1784451763543,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":18,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} -{"type":"assistant/chunk","seq":19,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":20,"time":1784451763568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":21,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} -{"type":"assistant/chunk","seq":22,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":23,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":24,"time":1784451763596,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":25,"time":1784451763618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":26,"time":1784451763649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} -{"type":"assistant/chunk","seq":27,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":28,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":29,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":30,"time":1784451763650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":31,"time":1784451763688,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":32,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":33,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":34,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":35,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":36,"time":1784451763689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":37,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":38,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":39,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":40,"time":1784451763704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":41,"time":1784451763779,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":42,"time":1784451763779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":43,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":44,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1784451763807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1784451763833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":50,"time":1784451763863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":51,"time":1784451763864,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" CH"}}} -{"type":"assistant/chunk","seq":52,"time":1784451763864,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":53,"time":1784451763885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":54,"time":1784451763885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" only"}}} -{"type":"assistant/chunk","seq":55,"time":1784451763911,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":57,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1784451763939,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"prom"}}} -{"type":"assistant/chunk","seq":59,"time":1784451763940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"pt"}}} -{"type":"assistant/chunk","seq":60,"time":1784451763940,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":62,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":63,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":64,"time":1784451763964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":65,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":66,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":67,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":68,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" CH"}}} -{"type":"assistant/chunk","seq":69,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":70,"time":1784451763996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":71,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":72,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":73,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":74,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"."}}} -{"type":"assistant/chunk","seq":75,"time":1784451764019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":76,"time":1784451764043,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":77,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool exactly once to delegate a specific task, then after it returns, reply with \"PARENT_DONE\". Let me do that."}}}} -{"type":"assistant/chunk","seq":78,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":79,"time":1784451764211,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3593,"outputTokens":112,"cacheReadTokens":0,"reasoningTokens":36}}}} -{"type":"assistant/chunk","seq":80,"time":1784451764212,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":81,"time":1784451764213,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool exactly once to delegate a specific task, then after it returns, reply with \"PARENT_DONE\". Let me do that."},{"type":"tool-call","id":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3593,"outputTokens":112,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"} -{"type":"tool/call","seq":82,"time":1784451764214,"data":{"turn":1,"step":1,"callId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK only\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":83,"time":1784451765917,"data":{"turn":1,"step":1,"callId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","content":[{"type":"text","text":"CHILD_OK"}],"isError":false},"sourceEventSeqs":[82],"surfaceOp":"append"} -{"type":"step/end","seq":84,"time":1784451765918,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":85,"time":1784451765918,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":86,"time":1784451767064,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":87,"time":1784451767064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":88,"time":1784451767169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} -{"type":"assistant/chunk","seq":89,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} -{"type":"assistant/chunk","seq":90,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":91,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":92,"time":1784451767199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} -{"type":"assistant/chunk","seq":93,"time":1784451767202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":94,"time":1784451767202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":95,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":96,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":97,"time":1784451767224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} -{"type":"assistant/chunk","seq":98,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":99,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":100,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":101,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":102,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":103,"time":1784451767278,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":104,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":105,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":106,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} -{"type":"assistant/chunk","seq":107,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} -{"type":"assistant/chunk","seq":108,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":109,"time":1784451767284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":110,"time":1784451767312,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":111,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":112,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":113,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":114,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":115,"time":1784451767313,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} -{"type":"assistant/chunk","seq":116,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} -{"type":"assistant/chunk","seq":117,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":118,"time":1784451767343,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":119,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":120,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":121,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":136,"outputTokens":32,"cacheReadTokens":3584,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":122,"time":1784451767350,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":123,"time":1784451767351,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":136,"outputTokens":32,"cacheReadTokens":3584,"reasoningTokens":27}},"sourceEventSeqs":[86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122],"surfaceOp":"append"} -{"type":"step/end","seq":124,"time":1784451767351,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":125,"time":1784451767351,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"/tmp/acp-snap-cwd-rbeWyt","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352119275,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352119281,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352119925,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352119925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352120053,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352120080,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352120081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352120081,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352120111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":11,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":12,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":13,"time":1783352120112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}} +{"type":"assistant/chunk","seq":14,"time":1783352120113,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":15,"time":1783352120136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":16,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":17,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":19,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":20,"time":1783352120137,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":21,"time":1783352120164,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" delegate"}}} +{"type":"assistant/chunk","seq":22,"time":1783352120164,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":23,"time":1783352120192,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" task"}}} +{"type":"assistant/chunk","seq":24,"time":1783352120192,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":25,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":26,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":27,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":28,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":29,"time":1783352120221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":30,"time":1783352120222,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":31,"time":1783352120248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} +{"type":"assistant/chunk","seq":32,"time":1783352120248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":33,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":34,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":35,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":36,"time":1783352120249,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":37,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n"}}} +{"type":"assistant/chunk","seq":38,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":39,"time":1783352120277,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783352120278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":41,"time":1783352120278,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":42,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":43,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":44,"time":1783352120305,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":45,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":46,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":47,"time":1783352120306,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":48,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":50,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":51,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" PAR"}}} +{"type":"assistant/chunk","seq":52,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":53,"time":1783352120334,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":54,"time":1783352120361,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":55,"time":1783352120362,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":56,"time":1783352120362,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":57,"time":1783352120394,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n"}}} +{"type":"assistant/chunk","seq":58,"time":1783352120395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":59,"time":1783352120395,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":60,"time":1783352120396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Do"}}} +{"type":"assistant/chunk","seq":61,"time":1783352120396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":62,"time":1783352120397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":63,"time":1783352120397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":64,"time":1783352120421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":65,"time":1783352120421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":66,"time":1783352120449,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":67,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":68,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":69,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":70,"time":1783352120450,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":71,"time":1783352120476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":72,"time":1783352120532,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":73,"time":1783352120532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":74,"time":1783352120559,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":75,"time":1783352120560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1783352120560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":77,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":79,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":80,"time":1783352120588,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":81,"time":1783352120617,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":82,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":83,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":84,"time":1783352120618,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":85,"time":1783352120643,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":87,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":88,"time":1783352120671,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":89,"time":1783352120672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":90,"time":1783352120672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1783352120700,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":92,"time":1783352120702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":93,"time":1783352120702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":94,"time":1783352120703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":95,"time":1783352120728,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":96,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":97,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":98,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":99,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":100,"time":1783352120729,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":101,"time":1783352120765,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":102,"time":1783352120765,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":103,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":104,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":105,"time":1783352120766,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":106,"time":1783352120784,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":107,"time":1783352120851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}}} +{"type":"assistant/chunk","seq":108,"time":1783352120851,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":109,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}}}} +{"type":"assistant/chunk","seq":110,"time":1783352120852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":111,"time":1783352120854,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."},{"type":"tool-call","id":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2907,"outputTokens":142,"cacheReadTokens":0,"reasoningTokens":67}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} +{"type":"tool/call","seq":112,"time":1783352120854,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":113,"time":1783352121784,"data":{"turn":1,"step":1,"callId":"call_00_gVbLWC12Qu8JheZpVRRz8749","content":[{"type":"text","text":"CHILD_OK"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1783352121784,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":115,"time":1783352121785,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":116,"time":1783352122364,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":117,"time":1783352122364,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":118,"time":1783352122524,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":119,"time":1783352122552,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":120,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":121,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":122,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":123,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":124,"time":1783352122553,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":125,"time":1783352122581,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1783352122582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":127,"time":1783352122582,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} +{"type":"assistant/chunk","seq":128,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":130,"time":1783352122610,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":131,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":132,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":133,"time":1783352122611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":134,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":135,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":136,"time":1783352122642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":137,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":138,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":139,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":140,"time":1783352122674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":141,"time":1783352122675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":142,"time":1783352122675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":143,"time":1783352122701,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":144,"time":1783352122701,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":145,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":146,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":147,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":148,"time":1783352122702,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":149,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":150,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":151,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":152,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":153,"time":1783352122731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":154,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":155,"time":1783352122732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":156,"time":1783352122732,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":120,"outputTokens":35,"cacheReadTokens":2944,"reasoningTokens":30}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} +{"type":"step/end","seq":157,"time":1783352122732,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":158,"time":1783352122732,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl index 1228921ca4..2b77e856e6 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl @@ -5,7 +5,10 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Use"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} @@ -14,30 +17,58 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" delegate"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" task"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" after"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK only","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_mQHKZdWXRpPe4vDiwr5L0196","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} @@ -56,6 +87,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"PAR"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index b393644c5b..eb8d9ed63e 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,36 +1,36 @@ -{"type":"session","version":0,"id":"2903e21a-fb6b-4d36-9a78-b9240419c334","createdAt":1784451805556,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5","parentSession":"6789922c-5a8c-4141-8336-0f9b0809bb17","delegationDepth":1} -{"type":"turn/start","seq":0,"time":1784451805557,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451805557,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451805557,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451805557,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451807175,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451807175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451807383,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":11,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":12,"time":1784451807416,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":13,"time":1784451807459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1784451807459,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":15,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":16,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":17,"time":1784451807460,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":18,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":20,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} -{"type":"assistant/chunk","seq":21,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} -{"type":"assistant/chunk","seq":22,"time":1784451807472,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":23,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} -{"type":"assistant/chunk","seq":25,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} -{"type":"assistant/chunk","seq":26,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} -{"type":"assistant/chunk","seq":27,"time":1784451807501,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} -{"type":"assistant/chunk","seq":28,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":29,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":30,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3550,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":31,"time":1784451807504,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":32,"time":1784451807505,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3550,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} -{"type":"step/end","seq":33,"time":1784451807505,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":34,"time":1784451807506,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600638173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":15,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":16,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":25,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":26,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index a520e28d08..20f4e296cd 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,143 +1,209 @@ -{"type":"session","version":0,"id":"6789922c-5a8c-4141-8336-0f9b0809bb17","createdAt":1784451802866,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-Uzz8l5","delegationDepth":0} -{"type":"turn/start","seq":0,"time":1784451802869,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1784451802870,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1784451802872,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1784451802873,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1784451804371,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1784451804371,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1784451804483,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1784451804504,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1784451804505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1784451804505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1784451804506,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1784451804537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":12,"time":1784451804537,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":13,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":14,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} -{"type":"assistant/chunk","seq":15,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1784451804561,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} -{"type":"assistant/chunk","seq":17,"time":1784451804588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} -{"type":"assistant/chunk","seq":18,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":19,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":20,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":21,"time":1784451804589,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":22,"time":1784451804613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":23,"time":1784451804613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":24,"time":1784451804640,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":25,"time":1784451804641,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":26,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":27,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":28,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":29,"time":1784451804733,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":30,"time":1784451804746,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"meta"}}} -{"type":"assistant/chunk","seq":31,"time":1784451804778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":32,"time":1784451804778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":33,"time":1784451804779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"{\""}}} -{"type":"assistant/chunk","seq":34,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"name"}}} -{"type":"assistant/chunk","seq":35,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":36,"time":1784451804808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":37,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"sn"}}} -{"type":"assistant/chunk","seq":38,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"apshot"}}} -{"type":"assistant/chunk","seq":39,"time":1784451804809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"-flow"}}} -{"type":"assistant/chunk","seq":40,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\","}}} -{"type":"assistant/chunk","seq":41,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":42,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":43,"time":1784451804833,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\":"}}} -{"type":"assistant/chunk","seq":44,"time":1784451804834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" \""}}} -{"type":"assistant/chunk","seq":45,"time":1784451804834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"one"}}} -{"type":"assistant/chunk","seq":46,"time":1784451804858,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" child"}}} -{"type":"assistant/chunk","seq":47,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" for"}}} -{"type":"assistant/chunk","seq":48,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":49,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" snapshot"}}} -{"type":"assistant/chunk","seq":50,"time":1784451804859,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1784451804882,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":52,"time":1784451804923,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":53,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":54,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"script"}}} -{"type":"assistant/chunk","seq":55,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1784451804924,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":57,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"phase"}}} -{"type":"assistant/chunk","seq":59,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":60,"time":1784451804943,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":61,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"')\\n"}}} -{"type":"assistant/chunk","seq":62,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"const"}}} -{"type":"assistant/chunk","seq":63,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":64,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" ="}}} -{"type":"assistant/chunk","seq":65,"time":1784451805452,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" await"}}} -{"type":"assistant/chunk","seq":66,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" agent"}}} -{"type":"assistant/chunk","seq":67,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"('"}}} -{"type":"assistant/chunk","seq":68,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"Reply"}}} -{"type":"assistant/chunk","seq":69,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" with"}}} -{"type":"assistant/chunk","seq":70,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" exactly"}}} -{"type":"assistant/chunk","seq":71,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":72,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" word"}}} -{"type":"assistant/chunk","seq":73,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" WF"}}} -{"type":"assistant/chunk","seq":74,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"_CH"}}} -{"type":"assistant/chunk","seq":75,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"ILD"}}} -{"type":"assistant/chunk","seq":76,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":77,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" and"}}} -{"type":"assistant/chunk","seq":78,"time":1784451805455,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" nothing"}}} -{"type":"assistant/chunk","seq":79,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" else"}}} -{"type":"assistant/chunk","seq":80,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":".')\\n"}}} -{"type":"assistant/chunk","seq":81,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"return"}}} -{"type":"assistant/chunk","seq":82,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" {"}}} -{"type":"assistant/chunk","seq":83,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" reply"}}} -{"type":"assistant/chunk","seq":84,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":" }"}}} -{"type":"assistant/chunk","seq":85,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":86,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":87,"time":1784451805456,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific workflow script with specific parameters. Let me do exactly as instructed."}}}} -{"type":"assistant/chunk","seq":88,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}}}} -{"type":"assistant/chunk","seq":89,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3635,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":21}}}} -{"type":"assistant/chunk","seq":90,"time":1784451805457,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":91,"time":1784451805459,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific workflow script with specific parameters. Let me do exactly as instructed."},{"type":"tool-call","id":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3635,"outputTokens":124,"cacheReadTokens":0,"reasoningTokens":21}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} -{"type":"tool/call","seq":92,"time":1784451805460,"data":{"turn":1,"step":1,"callId":"call_00_dQ54AjERB7zcWCD6mkku2211","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"phase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\"}"}} -{"type":"tool/result","seq":93,"time":1784451807534,"data":{"turn":1,"step":1,"callId":"call_00_dQ54AjERB7zcWCD6mkku2211","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[92],"surfaceOp":"append"} -{"type":"step/end","seq":94,"time":1784451807534,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":95,"time":1784451807535,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":96,"time":1784451809061,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":97,"time":1784451809061,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":98,"time":1784451809087,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} -{"type":"assistant/chunk","seq":99,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":100,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":101,"time":1784451809118,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":102,"time":1784451809146,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":103,"time":1784451809146,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":104,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":105,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} -{"type":"assistant/chunk","seq":106,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} -{"type":"assistant/chunk","seq":107,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} -{"type":"assistant/chunk","seq":108,"time":1784451809172,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":109,"time":1784451809173,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":110,"time":1784451809203,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":111,"time":1784451809203,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":112,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":113,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":114,"time":1784451809204,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":115,"time":1784451809222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":116,"time":1784451809222,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":117,"time":1784451809381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":118,"time":1784451809381,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":119,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":120,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} -{"type":"assistant/chunk","seq":121,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} -{"type":"assistant/chunk","seq":122,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} -{"type":"assistant/chunk","seq":123,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} -{"type":"assistant/chunk","seq":124,"time":1784451809382,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":125,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":127,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":128,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":129,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":130,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} -{"type":"assistant/chunk","seq":131,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} -{"type":"assistant/chunk","seq":132,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} -{"type":"assistant/chunk","seq":133,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} -{"type":"assistant/chunk","seq":134,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":135,"time":1784451809383,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":136,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":137,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":210,"outputTokens":38,"cacheReadTokens":3584,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":138,"time":1784451809384,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":139,"time":1784451809384,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with the single word \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":210,"outputTokens":38,"cacheReadTokens":3584,"reasoningTokens":32}},"sourceEventSeqs":[96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138],"surfaceOp":"append"} -{"type":"step/end","seq":140,"time":1784451809384,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":141,"time":1784451809384,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":13,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":19,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":21,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":22,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":23,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":24,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":26,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":27,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":28,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} +{"type":"assistant/chunk","seq":30,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} +{"type":"assistant/chunk","seq":31,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":32,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"so"}}} +{"type":"assistant/chunk","seq":33,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":34,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":35,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":36,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} +{"type":"assistant/chunk","seq":37,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":38,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} +{"type":"assistant/chunk","seq":39,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":40,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meta"}}} +{"type":"assistant/chunk","seq":42,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":43,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":44,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"name"}}} +{"type":"assistant/chunk","seq":46,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":47,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":48,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sn"}}} +{"type":"assistant/chunk","seq":49,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"apshot"}}} +{"type":"assistant/chunk","seq":50,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-flow"}}} +{"type":"assistant/chunk","seq":51,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":52,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":53,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} +{"type":"assistant/chunk","seq":54,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":55,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":56,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":57,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":58,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":59,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":61,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" }\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":64,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":66,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":67,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":68,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} +{"type":"assistant/chunk","seq":69,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":70,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":71,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":72,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":73,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":75,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":76,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":77,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":78,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":82,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":83,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":84,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":85,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":87,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":88,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":90,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":91,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":92,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":94,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":95,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":97,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"meta"}}} +{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":99,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":100,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":101,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"name"}}} +{"type":"assistant/chunk","seq":102,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":103,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":104,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":105,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":106,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":107,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":108,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":109,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":110,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":111,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":113,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":114,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":115,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":117,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":119,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":122,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":123,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":124,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":126,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":127,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":128,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":129,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":130,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":131,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":132,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":133,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":134,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":135,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":136,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":137,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":138,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":139,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":140,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":141,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":142,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":143,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":144,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":145,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":146,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":147,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":148,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":149,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":150,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":151,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":152,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" }\\n"}}} +{"type":"assistant/chunk","seq":153,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} +{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} +{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} +{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} +{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":165,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":166,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":167,"time":1783600640162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":168,"time":1783600640195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":169,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":170,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":172,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":174,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":175,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":176,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":177,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":178,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":179,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":180,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":181,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":182,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":183,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":184,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":185,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":186,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":187,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":188,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":189,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":190,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":191,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":192,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":193,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":194,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":196,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":197,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":199,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index fb2820212c..03f482bcc6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -5,24 +5,91 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" parameters"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" args"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" omitted"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" don"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" include"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":")\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" meta"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" {"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"name"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sn"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"apshot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-flow"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"description"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\":"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" snapshot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" }\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" script"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ="}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" given"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"OW"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" do"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dQ54AjERB7zcWCD6mkku2211","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"phase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dQ54AjERB7zcWCD6mkku2211","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","title":"workflow: snapshot-flow","kind":"other","status":"in_progress","rawInput":"\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\n"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" workflow"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} @@ -42,9 +109,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WORK"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FL"}}}} From 015ad420af375aa17280962d15265351997ccdcf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:06:22 +0800 Subject: [PATCH 097/132] fix(goal): align human commands with blocker reasons --- .../2026-07-19-human-goal-command.i18n.yaml | 4 +- .../feature/2026-07-19-human-goal-command.md | 8 ++-- .../2026-07-19-human-goal-command.zh.md | 8 ++-- ...26-07-19-model-facing-goal-tools.i18n.yaml | 4 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 21 ++++++--- .../stdio-demo/tests/stdio-agent.spec.ts | 2 +- packages/goal/command-goal/README.md | 4 +- packages/goal/command-goal/src/index.ts | 12 ++--- .../command-goal/tests/command-goal.spec.ts | 44 +++++-------------- 10 files changed, 48 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 78bb900b27..3505cfabca 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-human-goal-command.md: f458feae3bed8ef7b0ace6b8baaf5ae8b43e4cc8 -2026-07-19-human-goal-command.zh.md: 2fd79c2f50490e077cc69e93705a7b7bb242a082 +2026-07-19-human-goal-command.md: e2a59c3bddd7e135b0878cb59c964c3e7be64002 +2026-07-19-human-goal-command.zh.md: 0d6a0e22de091cd7a4a00ef59f1ac1b936c930f9 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index f458feae3b..e2a59c3bdd 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -12,7 +12,7 @@ The command must also respect the goal design's two kinds of state. Durable phas ## Decision -`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition for the TUI and ACP surfaces. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. +`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition, so every command adapter in the composition discovers the same command; an incompatible app omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. The command follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. The commit permalink makes the researched grammar durable even as Codex evolves. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. @@ -30,7 +30,7 @@ Control words are ASCII-case-insensitive after outer whitespace trimming. They a ### Output and failure boundary -Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or resumable stopped goal offers resume, a budget-limited goal explains that the agent must raise `maxGoalRounds` before resume, and a completed goal offers replacement or clear. +Status output omits branded ids and compare-and-set revisions because those are model/plugin coordination details rather than human controls. It includes activation because that fact changes whether work will continue, and a blocked goal includes its durable policy code and human-readable explanation. Command hints are derived from the exact state: an armed active goal offers pause, a disarmed active or paused/blocked goal offers resume, and a completed goal offers replacement or clear. Expected `GoalError` failures become one stable, branded-id-free `CommandResult.error`, so domain diagnostics do not leak compare-and-set internals into the human surface and invalid operations never enter model history. The current status supplies the actionable state-specific recovery. Other exceptions remain adapter-visible command failures; treating programmer faults as ordinary domain errors would hide defects. The command handler performs only synchronous domain mutations, so request cancellation is decided by the command registry before the mutation begins and there is no escaped asynchronous side effect to unwind. @@ -40,11 +40,11 @@ Generic slash input, status text, and errors are not persisted. Successful goal `agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation. -The interactive app bundles make the opposite product choice. ACP defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer. The terminal app enables the same goal stack by default but mounts the producer only for TUI mode; line-oriented stdio does not consume the command plane, so a typed `/goal` there remains an ordinary human prompt that the model may interpret through its separately authorized goal tools. Both apps accept `goals: false` as one coherent stack opt-out. The Python SDK runtime closure ships this producer alongside ACP, commands, and the goal stack so an external `cordis.yml` can compose the same command surface. +The interactive app bundles make the opposite product choice. ACP defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer. The terminal app enables the same goal stack by default but mounts the producer only for TUI mode; line-oriented stdio does not consume the command plane, so a typed `/goal` there remains an ordinary human prompt that the model may interpret through its separately authorized goal tools. Both apps accept `goals: false` as one coherent stack opt-out. The Python SDK runtime closure ships this producer alongside ACP, commands, and the goal stack so an external `cordis.yml` can compose the same command. ## Testing -The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, discovery on both surfaces, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, armed/disarmed presentation, budget-exhaustion recovery guidance, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI/ACP defaults, readline producer absence, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. The keyless ACP snapshots pin the resulting `/goal` discovery metadata and goal tool schemas in the shipped app composition. +The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI/ACP defaults, readline producer absence, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. A keyless snapshot boots the shipped ACP application, observes its advertised `/goal` metadata, invokes `/goal` directly, and pins the no-model-turn result; the surrounding ACP snapshots also pin the goal tool schemas in that composition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 2fd79c2f50..0d6a0e22de 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它为 TUI 和 ACP 表面注册一个全局 `goal` 定义。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 +位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它注册一个全局 `goal` 定义,因此组合中的每个命令适配器都会发现同一个命令;不兼容的应用应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、回合计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 @@ -30,7 +30,7 @@ Status: implemented ### 输出与失败边界 -状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或可恢复的停止目标提供恢复,受预算限制的目标说明 agent 必须先提高 `maxGoalRounds` 才能恢复,已完成目标则提供替换或清除。 +状态输出省略品牌化 id 与比较并交换修订号,因为它们属于模型/插件协调细节,而不是人类控制项。输出包含激活态,因为该事实会改变工作是否继续;被阻塞的目标还会包含其持久策略代码和面向人类的说明。命令提示从准确状态派生:已激活的活跃目标提供暂停,未激活的活跃目标或已暂停/被阻塞目标提供恢复,已完成目标则提供替换或清除。 预期的 `GoalError` 失败会变为一个稳定且不含品牌化 id 的 `CommandResult.error`,使领域诊断不会向人类表面泄露比较并交换内部细节,非法操作也绝不会进入模型历史。当前状态负责提供针对具体状态且可执行的恢复路径。其他异常仍是适配器可见的命令失败;若把程序缺陷当成普通领域错误,就会隐藏问题。命令处理器只执行同步领域变更,因此请求取消会在变更开始前由命令注册表决定,不存在需要回滚的外逸异步副作用。 @@ -40,11 +40,11 @@ Status: implemented `agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在一个相关物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 -交互式应用包作出相反的产品选择。ACP 默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方。终端应用默认启用相同目标栈,但只在 TUI 模式挂载本生产方;行式 stdio 不消费命令平面,因此在那里输入的 `/goal` 仍是普通人类提示词,模型可以通过独立授权的目标工具解释它。两个应用都接受 `goals: false` 作为一致的整体退出选项。Python SDK 运行时闭包把本生产方与 ACP、命令及目标栈一并交付,使外部 `cordis.yml` 能组合相同命令表面。 +交互式应用包作出相反的产品选择。ACP 默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方。终端应用默认启用相同目标栈,但只在 TUI 模式挂载本生产方;行式 stdio 不消费命令平面,因此在那里输入的 `/goal` 仍是普通人类提示词,模型可以通过独立授权的目标工具解释它。两个应用都接受 `goals: false` 作为一致的整体退出选项。Python SDK 运行时闭包把本生产方与 ACP、命令及目标栈一并交付,使外部 `cordis.yml` 能组合相同命令。 ## 测试 -生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、两个表面的发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、已激活/未激活展示、预算耗尽恢复提示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI/ACP 默认值、readline 不挂载生产方、一致退出、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。无密钥 ACP 快照固定了交付应用组合中的 `/goal` 发现元数据和目标工具 schema。 +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、资源释放、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、所有缺失状态控制、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI/ACP 默认值、readline 不挂载生产方、一致退出、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。一个无密钥快照会启动交付的 ACP 应用,观察其公布的 `/goal` 元数据,直接调用 `/goal`,并固定不经过模型轮次的结果;周边 ACP 快照还会固定该组合中的目标工具 schema。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index fd97351a0e..e53c591aa5 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 2ef77b53cd8b95c9cdffd12e20c723fb1cff3a5d -2026-07-19-model-facing-goal-tools.zh.md: 08619600355c71ccd30d608e3c4e5a7fba753d4e +2026-07-19-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b +2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index a9a1d6178d..b6886a7e12 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -96,7 +96,7 @@ describe('dsh-acp-demo composition', () => { sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(ctx.commands.find(handle.agent, 'acp', 'goal')).toBeUndefined() + expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() await handle.dispose() await ctx.fiber.dispose() }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index acb93f758b..6c8837652d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -121,14 +121,16 @@ describe('dsh-agent-spine-demo bundle', () => { it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => { const ctx = await mount({ workspaceContext: false, + agents: [{ id: SessionId('configured-goal'), provider: 'mock', model: 'mock' }], goals: { domain: { defaultMaxGoalRounds: 17 }, tool: { blockedAfterConsecutiveRounds: 5 }, }, }) - expect(ctx.goals.resolveCreate({ objective: 'configured' })).toEqual({ - objective: 'configured', - maxGoalRounds: 17, + const agent = ctx.agents.list()[0] + if (agent === undefined) throw new Error('configured goal test has no live agent') + expect(ctx.goals.create(agent, { objective: 'configured' })).toMatchObject({ + objective: 'configured', maxGoalRounds: 17, }) expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) .toEqual(['create_goal', 'get_goal', 'update_goal']) @@ -199,11 +201,16 @@ describe('dsh-agent-spine-demo bundle', () => { it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => { const ctx = new Context() - agentCore.apply(ctx, { workspaceContext: false, goals: {} }) + agentCore.apply(ctx, { + workspaceContext: false, + agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }], + goals: {}, + }) await new Promise(resolve => setTimeout(resolve, 50)) - expect(ctx.goals.resolveCreate({ objective: 'defaulted' })).toEqual({ - objective: 'defaulted', - maxGoalRounds: 256, + const agent = ctx.agents.list()[0] + if (agent === undefined) throw new Error('default goal test has no live agent') + expect(ctx.goals.create(agent, { objective: 'defaulted' })).toMatchObject({ + objective: 'defaulted', maxGoalRounds: 256, }) expect(ctx.tools.get('get_goal')).toBeDefined() await ctx.fiber.dispose() diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 2480cb704d..de8481da4f 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -153,7 +153,7 @@ describe('dsh-stdio-demo app', () => { expect(agent?.id).toBe(agent?.session.id) expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) - expect(ctx.commands.find(agent!, 'tui', 'goal')).toBeUndefined() + expect(ctx.commands.find(agent!, 'goal')).toBeUndefined() await ctx.fiber.dispose() }) diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index 2fa7323789..f766cd3ece 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -1,12 +1,12 @@ # @deepseek-ai/dsh-command-goal -Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md); TUI and ACP discover and execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. +Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions. ## Command contract | Input | Result | |---|---| -| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; show usage when no goal exists. | +| `/goal` | Show the current objective, durable phase, round count/cap, process-local activation, and valid next commands; a blocked goal also shows its policy code and explanation, while no goal shows usage. | | `/goal ` | Create and arm a goal, or replace a completed goal with a fresh identity. An unfinished goal is never replaced without an explicit clear. | | `/goal edit ` | Edit the current objective without changing its phase or activation. Editing a completed goal creates a fresh active goal. | | `/goal pause` | Pause an active goal and disarm continuation. | diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index a8e73cba7e..93ed7923b8 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -48,8 +48,6 @@ function phaseLabel(phase: GoalPhase): string { case 'active': return 'active' case 'paused': return 'paused' case 'blocked': return 'blocked' - case 'usage-limited': return 'usage limited' - case 'budget-limited': return 'limited by round budget' case 'complete': return 'complete' /* v8 ignore next 2 -- GoalPhase is closed and every member is handled above */ default: return assertNever(phase, 'goal phase') @@ -66,10 +64,7 @@ function commandHint(goal: GoalView): string { switch (goal.phase) { case 'paused': case 'blocked': - case 'usage-limited': return '/goal edit , /goal resume, /goal clear' - case 'budget-limited': - return '/goal edit , /goal clear; after the agent raises the round cap, /goal resume' case 'complete': return '/goal , /goal clear' /* v8 ignore next 2 -- the active branch and every non-active phase are handled above */ @@ -79,11 +74,16 @@ function commandHint(goal: GoalView): string { /** Render direct UI output without exposing compare-and-set internals. */ function renderGoal(title: string, goal: GoalView): CommandResult { + const reason = goal.phase === 'blocked' ? goal.blockedReason : undefined + /* v8 ignore next -- durable replay guarantees every blocked goal carries its validated reason */ + if (goal.phase === 'blocked' && reason === undefined) throw new TypeError('blocked goal is missing its reason') + const blocker = reason === undefined ? [] : [`Blocker: ${reason.code}: ${reason.message}`] return { kind: 'success', text: [ title, `Status: ${phaseLabel(goal.phase)}`, + ...blocker, `Objective: ${goal.objective}`, `Rounds: ${goal.roundsStarted}/${goal.maxGoalRounds}`, `Activation: ${goal.activation}`, @@ -159,7 +159,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman } } -/** Register the Codex-shaped `/goal` human command on TUI and ACP surfaces. */ +/** Register the Codex-shaped `/goal` command for every composed command adapter. */ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index a3d48857c4..71b1c59052 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -74,7 +74,6 @@ async function harness(): Promise { async function run(test: Harness, suffix = ''): Promise>>> { const result = await test.ctx.commands.execute( test.agent, - 'tui', `/goal${suffix}`, new AbortController().signal, ) @@ -87,20 +86,8 @@ function ref(goal: NonNullable>): GoalRef { return { id: goal.id, revision: goal.revision } } -/** Append one admitted goal round for budget-limited presentation coverage. */ -function appendRound(test: Harness, goal: NonNullable>): void { - const source = { kind: 'goal', goalId: goal.id, revision: goal.revision, round: 1 } as const - const turn = nextTurn(test.session) - test.session.append('turn/start', { turn, trigger: { kind: 'message', source } }) - test.session.append('user/message', { - content: [{ type: 'text', text: 'goal round' }], - source, - }, { surfaceOp: 'append' }) - test.session.append('turn/end', { turn, reason: { kind: 'completed' } }) -} - describe('@deepseek-ai/dsh-command-goal registration', () => { - it('registers one global TUI/ACP command with Loader-safe exports and disposes it', async () => { + it('registers one global command with Loader-safe exports and disposes it', async () => { const test = await harness() expect(commandGoal.name).toBe('command-goal') expect(commandGoal.inject).toEqual(['commands', 'goals']) @@ -108,16 +95,15 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(commandGoal)).toBe(commandGoal) - expect(test.ctx.commands.list(test.agent, 'tui')).toContainEqual({ + expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '[|clear|edit |pause|resume]' }, - surfaces: ['tui', 'acp'], }) - expect(test.ctx.commands.find(test.agent, 'acp', 'goal')).toBeDefined() + expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() await test.plugin.dispose() - expect(test.ctx.commands.find(test.agent, 'tui', 'goal')).toBeUndefined() + expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined() }) }) @@ -227,22 +213,16 @@ describe('/goal human command', () => { expect((await run(test)).text).toContain('Status: paused') goal = test.ctx.goals.resume(test.agent, ref(goal)) - goal = test.ctx.goals.block(test.agent, ref(goal)) - expect((await run(test)).text).toContain('Status: blocked') + goal = test.ctx.goals.block(test.agent, ref(goal), { + code: 'upstream-unavailable', + message: 'Provider unavailable', + }) + const blocked = await run(test) + expect(blocked.text).toContain('Status: blocked') + expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable') goal = test.ctx.goals.resume(test.agent, ref(goal)) - goal = test.ctx.goals.markUsageLimited(test.agent, ref(goal)) - expect((await run(test)).text).toContain('Status: usage limited') - - goal = test.ctx.goals.resume(test.agent, ref(goal)) - appendRound(test, goal) - goal = test.ctx.goals.get(test.agent)! - goal = test.ctx.goals.markBudgetLimited(test.agent, ref(goal)) - const limited = await run(test) - expect(limited.text).toContain('Status: limited by round budget') - expect(limited.text).toContain('after the agent raises the round cap, /goal resume') - - goal = test.ctx.goals.complete(test.agent, ref(goal)) + test.ctx.goals.complete(test.agent, ref(goal)) const complete = await run(test) expect(complete.text).toContain('Status: complete') expect(complete.text).toContain('Commands: /goal , /goal clear') From 283c78eec88c3d1e3720c9f2c19dd5de04bf5549 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:06:34 +0800 Subject: [PATCH 098/132] test(goal): refresh shipped ACP goal snapshots --- examples/acp-agent/goal.cordis.snapshot.yml | 19 ------------------- examples/acp-agent/goal.cordis.yml | 13 ------------- .../goal-session/stdout.expected.jsonl | 2 +- examples/acp-agent/tests/goal.snapshot.ts | 2 +- .../system-prompt.expected.md | 8 +++++--- .../tool-schemas.expected.json | 8 ++++++-- .../both-mode-turn/system-prompt.expected.md | 8 +++++--- .../both-mode-turn/tool-schemas.expected.json | 8 ++++++-- .../code-mode-turn/system-prompt.expected.md | 8 +++++--- .../system-prompt.expected.md | 8 +++++--- .../model-switching/system-prompt.expected.md | 4 ++-- .../tool-schemas.expected.json | 16 ++++++++++++---- .../system-prompt.expected.md | 4 ++-- .../tool-schemas.expected.json | 16 ++++++++++++---- .../skill-load/system-prompt.expected.md | 2 +- .../skill-load/tool-schemas.expected.json | 8 ++++++-- .../text-turn/system-prompt.expected.md | 2 +- .../text-turn/tool-schemas.expected.json | 8 ++++++-- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 8 ++++++-- .../workspace-edit/system-prompt.expected.md | 2 +- .../workspace-edit/tool-schemas.expected.json | 8 ++++++-- 22 files changed, 90 insertions(+), 74 deletions(-) delete mode 100644 examples/acp-agent/goal.cordis.snapshot.yml delete mode 100644 examples/acp-agent/goal.cordis.yml diff --git a/examples/acp-agent/goal.cordis.snapshot.yml b/examples/acp-agent/goal.cordis.snapshot.yml deleted file mode 100644 index 89e1078117..0000000000 --- a/examples/acp-agent/goal.cordis.snapshot.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Replay counterpart to goal.cordis.yml; only the live model is replaced. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./goal.cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek - name: DeepSeek - models: - - id: deepseek-v4-flash - - id: deepseek-v4-pro diff --git a/examples/acp-agent/goal.cordis.yml b/examples/acp-agent/goal.cordis.yml deleted file mode 100644 index d077104bb8..0000000000 --- a/examples/acp-agent/goal.cordis.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Add the persisted same-session goal stack to the shipped ACP app. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - insert: - - id: goal - name: '@deepseek-ai/dsh-goal' - - id: tool-goal - name: '@deepseek-ai/dsh-tool-goal' - - id: goal-session - name: '@deepseek-ai/dsh-goal-session' diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 61602dc289..40f60aa7fa 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_create","title":"Create goal","kind":"other","status":"in_progress","rawInput":"Finish the ACP goal-session snapshot proof"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_goal_create","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_goal_get","title":"Read current goal","kind":"read","status":"in_progress"}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index 42e3041721..4bb01acd16 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -25,7 +25,7 @@ const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const agent: AgentUnderTest = { binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), - configPath: fileURLToPath(new URL('../goal.cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index e130790bdf..acdebe8036 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -70,7 +70,7 @@ declare const tools: { /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; }): Promise; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { @@ -123,7 +123,7 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ goal_id: string; @@ -135,6 +135,8 @@ declare const tools: { objective?: string; /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 55c1398f81..3a4f7ab7d8 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -124,7 +124,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -304,7 +304,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -334,6 +334,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 70de361b1d..271ac5e557 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -53,7 +53,7 @@ declare const tools: { /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; }): Promise; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { @@ -106,7 +106,7 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ goal_id: string; @@ -118,6 +118,8 @@ declare const tools: { objective?: string; /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 50d08b7946..e8a4d432ff 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -67,7 +67,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -247,7 +247,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -277,6 +277,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 70de361b1d..271ac5e557 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -53,7 +53,7 @@ declare const tools: { /** Optional positive safe-integer limit on automatic continuation rounds. */ max_goal_rounds?: number; }): Promise; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill(args: { @@ -106,7 +106,7 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ goal_id: string; @@ -118,6 +118,8 @@ declare const tools: { objective?: string; /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index c3fefc8b86..7b64ad8b0d 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -15,7 +15,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -70,7 +70,7 @@ declare const tools: { /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ replace_all?: boolean; }): Promise; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal. */ + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ get_goal(args: Record): Promise; /** Read a UTF-8 text file and return line-numbered content. */ read(args: { @@ -132,7 +132,7 @@ declare const tools: { status: "pending" | "in_progress" | "completed"; })[]; }): Promise; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds. */ + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal(args: { /** Exact id returned by get_goal. */ goal_id: string; @@ -144,6 +144,8 @@ declare const tools: { objective?: string; /** Replacement cap; valid only with action edit. */ max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; }): Promise; /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ workflow(args: { diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index f81876701a..abcd608b39 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -29,7 +29,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index 113459eb86..a4c5307864 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -67,7 +67,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -231,7 +231,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -261,6 +261,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -411,7 +415,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -575,7 +579,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -605,6 +609,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 82335a7f1e..04d1fbe817 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. @@ -28,7 +28,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index 113459eb86..a4c5307864 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -67,7 +67,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -231,7 +231,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -261,6 +261,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ @@ -411,7 +415,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -575,7 +579,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -605,6 +609,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 87818d5b6d..4cd64a91f8 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index 0d8966ec14..21542887b1 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -67,7 +67,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -231,7 +231,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -261,6 +261,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 87818d5b6d..4cd64a91f8 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -9,7 +9,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index 0d8966ec14..21542887b1 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -67,7 +67,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -231,7 +231,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -261,6 +261,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 6f49fcf1c2..dd965e4933 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -15,7 +15,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index ab8c4a3d20..322ac0eb8d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -97,7 +97,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -285,7 +285,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -315,6 +315,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md index 4af39f6b4b..5879f004af 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md @@ -15,7 +15,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds; difficulty, uncertainty, or useful remaining work is not blocked. +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json index ab8c4a3d20..322ac0eb8d 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json @@ -97,7 +97,7 @@ }, { "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, and whether another continuation is armed. Call this before updating a goal.", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", "parameters": { "type": "object", "properties": {} @@ -285,7 +285,7 @@ }, { "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds.", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", "parameters": { "type": "object", "properties": { @@ -315,6 +315,10 @@ "max_goal_rounds": { "type": "number", "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." } }, "required": [ From ff6359a0cda4c12dfd7244967a5eef86dcd9a3c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:10:31 +0800 Subject: [PATCH 099/132] docs(subagent): close review contract gaps --- .../2026-07-12-subagent-persona-tool-filter-and-depth.md | 2 +- docs/architecture.md | 2 +- docs/cordis-catalog/services.md | 6 +++--- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/subagent/src/index.ts | 1 + 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index ee7af6c20c..724d8303b2 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -51,7 +51,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations; deployments with shallower workflows set a lower value, and the shipped interactive examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. diff --git a/docs/architecture.md b/docs/architecture.md index 96c74801f7..af421c578e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ The shipped loop drains work from prompt through checkpoint. Every pause is a se A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. -Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. +Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Resume reconstructs durable session metadata before publication, so lineage, seed boundaries, and delegation depth survive restart. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. ### Turn Flow diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5daee2143..44bcc132e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -718,9 +718,9 @@ Persistence is intentionally not implemented here — persistence plugins subscr * Create a session owned by the calling fiber: disposing that fiber stops * event notification and removes the session from the store. `options.seed` * populates the session with a copy of those events (replay/fork); - * `options.meta` attaches creation metadata (validated absolute `cwd`, - * `parentSession` lineage) as the immutable {@link SessionHeader} (the store - * fills `version`/`id`/`createdAt`). + * `options.meta` attaches creation metadata (validated absolute `cwd`, seed + * and parent lineage, and delegation depth) as the immutable + * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). * * For an agent whose session must be torn down IN ORDER with its loop (so the * loop's final flush is captured before the store attachment ends), do NOT use this diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ccb01b7b59..604dbfd1b8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -367,7 +367,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', - jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`,\n * `parentSession` lineage) as the immutable {@link SessionHeader} (the store\n * fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', + jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', }, { signature: 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 9046ae4a94..00655f1f51 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -72,6 +72,7 @@ declare module '@deepseek-ai/dsh-agent' { * let it delegate as if it were top-level. * @param agent - the agent whose header and options carry the depth. * @returns its non-negative safe-integer depth. + * @throws if the runtime `AgentOptions.subagentDepth` is not a non-negative safe integer. */ export function delegationDepthOf(agent: Agent): number { const runtime = agent.options.subagentDepth From f84309acbefd5c9922845727436e5fae8c51e637 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:12:31 +0800 Subject: [PATCH 100/132] docs(subagent): ground depth default in consumers --- .../2026-07-12-subagent-persona-tool-filter-and-depth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 724d8303b2..0f42a547cf 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -51,7 +51,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations; deployments with shallower workflows set a lower value, and the shipped interactive examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [SDK helper's generated subagent entries](../../../../packages/sdk/helper/src/features/builtin/index.ts) and [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) use that general policy, while the shipped interactive ACP, headless, and REPL examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. From 0000b17cae47b11ad76115ffcce889c06607bd77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:14:22 +0800 Subject: [PATCH 101/132] docs(catalog): refresh subagent source anchors --- docs/cordis-catalog/events.md | 8 ++++---- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 82caa4247e..b78b395959 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -585,7 +585,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -602,7 +602,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:113`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -617,7 +617,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:118`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:119`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -639,7 +639,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:129`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 44bcc132e1..94d181d110 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -946,7 +946,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:179`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c95a39e148..1d450d3d90 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,10 +32,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:118`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:129`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | From 9b28ad785f03c49e2482b63fee267f8b231b2b1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:17:25 +0800 Subject: [PATCH 102/132] test(acp-snapshot): emit current session headers --- .../tests/fixtures/record-suite/rec-child/behavior.json | 4 ++-- .../tests/fixtures/record-suite/rec-pin/behavior.json | 2 +- .../tests/fixtures/suite/authored-error/behavior.json | 2 +- .../tests/fixtures/suite/blocked-log/behavior.json | 2 +- .../acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json | 2 +- .../tests/fixtures/suite/plain-turn/behavior.json | 4 ++-- packages/support/acp-snapshot/tests/suite.spec.ts | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json index d44a3a9698..fd06978be1 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/behavior.json @@ -2,11 +2,11 @@ "prompt": "respond", "logs": [ { "file": "b/parent.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]}, { "file": "b/child.jsonl", "lines": [ - { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}" }, + { "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} ] diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json index a24e30d80a..b0ed5f1a3f 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/behavior.json @@ -3,7 +3,7 @@ "logs": [{ "file": "b/main.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 4, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ] }] diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json index 808d9672b9..991de99fd6 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/behavior.json @@ -3,7 +3,7 @@ "logs": [{ "file": "b/main.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "turn/end", "seq": 1, "time": 9, "data": { "error": "model exploded" } } ] }] diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json index e0a438297d..209159da7d 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/behavior.json @@ -3,7 +3,7 @@ "logs": [{ "file": "b/main.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "hook/result", "seq": 1, "time": 8, "data": { "decision": "block", "durationMs": 37 } } ] }] diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json index 4b63893a62..ad4c368e49 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/behavior.json @@ -3,7 +3,7 @@ "logs": [{ "file": "b/main.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, { "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } }, { "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json index d5cbbf9d28..8903d0360e 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/behavior.json @@ -3,12 +3,12 @@ "echoWorkspace": true, "logs": [ { "file": "b/parent.jsonl", "lines": [ - { "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}" }, + { "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}", "delegationDepth": 0 }, { "type": "request/header", "seq": 0, "time": 5, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, { "type": "assistant/chunk", "seq": 1, "time": 5, "data": { "turn": 1, "step": 1, "chunk": { "type": "text-delta", "index": 0, "text": "hi" } } } ]}, { "file": "b/child.jsonl", "lines": [ - { "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}" }, + { "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 }, { "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } } ]} ] diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index ac6f944a4a..6fbb06bb9d 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -90,12 +90,12 @@ function staleRefreshFixtures(dir: string): void { writeFileSync(plainBehaviorFile, `${JSON.stringify(plainBehavior, null, 2)}\n`) writeFileSync(join(dir, 'blocked-log', 'session.jsonl'), [ - '{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd"}', + '{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd","delegationDepth":0}', '{"type":"hook/result","seq":1,"time":13,"data":{"decision":"stale","durationMs":99}}', '', ].join('\n')) writeFileSync(join(dir, 'authored-error', 'session.jsonl'), [ - '{"type":"session","id":"77777777-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/error-cwd"}', + '{"type":"session","id":"77777777-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/error-cwd","delegationDepth":0}', '{"type":"turn/end","seq":1,"time":9,"data":{"error":"stale"}}', '', ].join('\n')) From fdf5a5cb8bc9eee08b9f42ad5771bdd3d80818a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:17:32 +0800 Subject: [PATCH 103/132] docs(agent-loop): condense resume contract --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3f6dca63a6..0e41c1ed6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ The shipped loop drains prompt-to-checkpoint work through plugin-visible service A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the preceding claimed ordinary turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. -Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Resume reconstructs durable session metadata before publication, so lineage, seed boundaries, and delegation depth survive restart. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. +No id mints `-session-`; `sessionId` resumes/creates; `resumeSessionId` needs history. Resume restores lineage, seeds, and delegation depth pre-publication. Failures emit `agent-loop/config-start-failed(sessionId, error)`; front doors reject; teardown stays silent. ### Turn Flow From 9f6f87cf694c7915b58f9f353a289fe514fc2fd8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:18:08 +0800 Subject: [PATCH 104/132] test(ralph): snapshot fresh-agent rounds in headless app --- ...-fresh-agent-ralph-workflow-tool.i18n.yaml | 4 +- ...6-07-19-fresh-agent-ralph-workflow-tool.md | 2 +- ...7-19-fresh-agent-ralph-workflow-tool.zh.md | 2 +- .../headless-agent/ralph.cordis.snapshot.yml | 12 +++ .../headless-agent/tests/headless.snapshot.ts | 75 +++++++++++++++++++ .../tests/snapshots/ralph-loop/input.json | 8 ++ .../snapshots/ralph-loop/replay.override.json | 22 ++++++ .../snapshots/ralph-loop/session.1.jsonl | 6 ++ .../snapshots/ralph-loop/session.2.jsonl | 6 ++ .../tests/snapshots/ralph-loop/session.jsonl | 1 + .../ralph-loop/stream-json.expected.jsonl | 23 ++++++ 11 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 examples/headless-agent/ralph.cordis.snapshot.yml create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/input.json create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl create mode 100644 examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml index f0453085c5..9ef2bae475 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-fresh-agent-ralph-workflow-tool.md: 159ad7e39602d8b84ffafdb396ad1083f9c73009 -2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: bb6025952ada35526459101d53b3ea1fea622163 +2026-07-19-fresh-agent-ralph-workflow-tool.md: 46816b8cc06f0acf5615ffb44035c79ea13b6794 +2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 188a0bf50a7a6a683cd6aa1004c4d78673d3631d diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md index 159ad7e396..46816b8cc0 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md @@ -46,7 +46,7 @@ ACP and terminal presentation use a generic `ralph` card whose raw input is the Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node. -A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. +A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. A shipped keyless headless snapshot additionally boots the real `examples/headless-agent` composition, invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while ACP replay header snapshots pin the shipped schema and prompt-guidance transcript surface. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md index bb6025952a..188a0bf50a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md @@ -46,7 +46,7 @@ ACP 和终端展示使用通用 `ralph` 卡片,并把目标作为原始输入 单元测试覆盖配置与调用上限解析、provider 能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、处置、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明 provider 路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且 provider 覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。 -一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 +一项无密钥真实栈集成测试通过实际工作线程引擎、spawn provider、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一轮交接中、只产生一个阶段事件、终止完成以及两个子 agent 都被处置。同一真实栈还覆盖阻塞与轮次上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后达到子 agent 静止状态。一项已发布的无密钥无头快照还会启动真实的 `examples/headless-agent` 组合、调用 `ralph`、固定父级流式转录,并检查持久化日志中存在两个不同且无种子的子会话,且第一轮交接只出现在第二轮。工具测试固定通用调用/结果展示,而 ACP 重放请求头快照固定发布的 schema 与提示指导转录表面。 ## 考虑过的替代方案 diff --git a/examples/headless-agent/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml new file mode 100644 index 0000000000..e84bdfed31 --- /dev/null +++ b/examples/headless-agent/ralph.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index d799c6250d..d8a36aab59 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -17,6 +17,8 @@ const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.j const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) +const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') +const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -216,4 +218,77 @@ describe('headless stream-json snapshots', () => { if (refreshing) await writeFile(streamExpected, normalized) expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('replays two fresh Ralph rounds through the one-shot app', async () => { + const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop') + const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'Ralph loop headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-ralph-loop-', + binScript, + configPath: ralphConfigPath, + binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'), + DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'), + DSH_SNAPSHOT_CHILD_FILES: [ + join(ralphScenarioDir, 'session.1.jsonl'), + join(ralphScenarioDir, 'session.2.jsonl'), + ].join(delimiter), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(3) + const parent = logs.find(log => typeof log.header.parentSession !== 'string') + if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session') + const parentId = parent.header.id + expect(typeof parentId).toBe('string') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + expect(children).toHaveLength(2) + expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId]) + expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd]) + expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined]) + expect(new Set(children.map(child => child.header.id)).size).toBe(2) + + const parentRecords = parseJsonl(parent.content) + const parentCalls = parentRecords.filter(record => record.type === 'tool/call') + expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph']) + const parentResult = parentRecords.find(record => record.type === 'tool/result') + const parentResultData = parentResult?.data as JsonObject | undefined + expect(parentResultData?.isError).toBe(false) + expect(JSON.stringify(parentResultData?.content)).toContain('reported completion after 2 rounds') + + const childRecords = children.map(child => parseJsonl(child.content)) + const childPrompts = childRecords.map((records) => { + const message = records.find(record => record.type === 'user/message') + return JSON.stringify((message?.data as JsonObject | undefined)?.content) + }) + expect(childPrompts[0]).toContain('Ralph round: 1 of 2.') + expect(childPrompts[0]).toContain('(none — this is the first round)') + expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF') + expect(childPrompts[1]).toContain('Ralph round: 2 of 2.') + expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF') + for (const childPrompt of childPrompts) { + expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.') + expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop') + } + for (const records of childRecords) { + const calls = records.filter(record => record.type === 'tool/call') + expect(calls.map(record => (record.data as JsonObject | undefined)?.name)) + .toEqual(['structured_output']) + } + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/input.json b/examples/headless-agent/tests/snapshots/ralph-loop/input.json new file mode 100644 index 0000000000..42652a4ac5 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "Run a two-round fresh-agent Ralph loop to prove the shipped headless integration." + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json new file mode 100644 index 0000000000..1d7846f76b --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/replay.override.json @@ -0,0 +1,22 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_ralph", "name": "ralph", "argumentsDelta": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_ralph", "name": "ralph", "arguments": "{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "RALPH SNAPSHOT COMPLETE" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "RALPH SNAPSHOT COMPLETE" } }, + { "type": "usage", "usage": { "inputTokens": 30, "outputTokens": 4 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl new file mode 100644 index 0000000000..ca36330c36 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} +{"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} +{"type":"assistant/chunk","seq":3,"time":1783951001004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}}} +{"type":"assistant/chunk","seq":4,"time":1783951001005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl new file mode 100644 index 0000000000..c722158098 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl @@ -0,0 +1,6 @@ +{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} +{"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} +{"type":"assistant/chunk","seq":3,"time":1783951002004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":12}}}} +{"type":"assistant/chunk","seq":4,"time":1783951002005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl new file mode 100644 index 0000000000..c452fb5fa8 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl new file mode 100644 index 0000000000..727f84fd90 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl @@ -0,0 +1,23 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_ralph","name":"ralph","argumentsDelta":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","content":[{"type":"text","text":"Ralph worker reported completion after 2 rounds.\nFinal report:\n{\n \"status\": \"complete\",\n \"summary\": \"The Ralph snapshot objective is complete.\",\n \"evidence\": [\n \"Two fresh rounds completed through the shipped app.\"\n ],\n \"nextSteps\": [],\n \"blocker\": \"\"\n}"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"RALPH SNAPSHOT COMPLETE"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"RALPH SNAPSHOT COMPLETE","reason":{"kind":"completed"},"usage":{"inputTokens":50,"outputTokens":12}} From 8e95f305b0a45181b21596ab91532c5c1b69b0e3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:26:19 +0800 Subject: [PATCH 105/132] refactor(agent-loop): reuse loaded session metadata --- packages/core/agent-loop/src/index.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 519edfdb7e..7268182552 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -620,13 +620,7 @@ export class AgentLoop extends Service implements AgentFactory { transaction.assertActive() const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { seed: loaded.events, - meta: { - createdAt: loaded.meta.createdAt, - ...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd }, - ...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession }, - ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, - ...loaded.meta.delegationDepth === undefined ? {} : { delegationDepth: loaded.meta.delegationDepth }, - }, + meta: loaded.meta, }) const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) await transaction.waitFor(options.setup?.(agent.ctx)) From 07058e527cc87b81de4c2359bce67ff3244cf6a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:32:21 +0800 Subject: [PATCH 106/132] fix(snapshot): isolate concurrent spill roots --- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 14 ++++++++++-- .../support/acp-snapshot/src/normalize.ts | 2 +- .../tests/fixtures/fake-acp-agent.ts | 1 + .../acp-snapshot/tests/harness.spec.ts | 22 +++++++++++++++++++ .../acp-snapshot/tests/normalize.spec.ts | 15 +++++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index 5fc7479eaf..56a102f456 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -57,7 +57,7 @@ Normalization replaces session, cwd, protocol-id, timestamp, path, and process v ### Isolation: normalization now, sandbox later -Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. It does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. +Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. Concurrent replay runs own separate cwd, persistence, and fixed-length scenario-keyed spill roots, so one scenario's teardown cannot delete another's in-flight full-output recovery while real-path preview budgets remain stable. This tier does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index a6252d9aa3..9700f24702 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -18,8 +18,9 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' +import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' -import { join, delimiter } from 'node:path' +import { basename, dirname, join, delimiter } from 'node:path' import { ClientSideConnection, PROTOCOL_VERSION, @@ -152,6 +153,13 @@ export interface RunOptions { configPath?: string } +/** Derive one stable, fixed-length spill root owned by this scenario. */ +function scenarioSpillRoot(fixtureFile: string): string { + const scenario = basename(dirname(fixtureFile)) + const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9) + return `/tmp/dsh-acp-snap-${key}` +} + /** * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the * child and its temp dirs; always tears them down. Returns the captured stdout @@ -166,7 +174,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn expected outputs. - const spillRoot = '/tmp/dsh-acp-snapshot-spill' + // Scenario ownership also matters: replay runs concurrently, and one teardown + // must never delete another scenario's in-flight full-output recovery file. + const spillRoot = scenarioSpillRoot(opts.fixtureFile) // Everything past the temp-dir creation is followed by failure-safe cleanup, // so a failure in workspace seeding, spawn, or any step never leaks resources. let launched: LaunchedAcpTestAgent | undefined diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 0c21046eb2..6671959b29 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -20,7 +20,7 @@ const LOCAL_SPILL_PATH_RE = new RegExp( 'g', ) const SNAPSHOT_SPILL_PATH_RE = new RegExp( - String.raw`/tmp/dsh-acp-snapshot-spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)` + String.raw`(?=\. Use read with offset/limit|[\s)]|$)`, 'g', ) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 43b5ee3fb3..277dc7565c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -152,6 +152,7 @@ async function handlePrompt(id: number | string): Promise { mode: process.env.DSH_SNAPSHOT, override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null, childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null, + spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null, })}`) } if (behavior.echoWorkspace === true) { diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 748b9e1606..2f54b7781d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] +function environmentEcho(rawStdout: string): Record { + const frames = rawStdout.trim().split('\n') + .map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } }) + const text = frames.map(frame => frame.params?.update?.content?.text) + .find(value => typeof value === 'string' && value.startsWith('env:')) + if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment') + return JSON.parse(text.slice('env:'.length)) as Record +} + describe('runScenario', () => { it('surfaces an asynchronous child spawn failure through startup and close', async () => { const { dir } = await scenario({}) @@ -309,6 +318,19 @@ describe('runScenario', () => { expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1)) }) + it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => { + const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })]) + const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario( + { steps: [...boot, { op: 'prompt', text: 'env?' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ))) + const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot) + expect(roots.every(root => typeof root === 'string')).toBe(true) + expect(new Set(roots).size).toBe(2) + expect((roots[0] as string).length).toBe((roots[1] as string).length) + expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length) + }) + it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ echoWorkspace: true }) const workspaceDir = join(dir, 'workspace') diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index 2beaba5114..0ebfbe3255 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -139,6 +139,21 @@ describe('normalizeSessionLog', () => { expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill') }) + it('scrubs scenario-owned snapshot spill paths', () => { + const ev = JSON.stringify({ + type: 'tool/result', seq: 2, time: 5, + data: { + content: [{ + type: 'text', + text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.', + }], + }, + }) + const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx) + expect(out).toContain('{{spillLocator:bash.txt}}') + expect(out).not.toContain('/tmp/dsh-acp-snap-012345678') + }) + it('scrubs the session id in the header', () => { const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx) expect(out).toContain('{{sessionId}}') From 3293d56a066865faec7614f2be7cae3f707d7bbc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:38:22 +0800 Subject: [PATCH 107/132] fix: clarify provider retry delay contract --- .../2026-06-21-bounded-llm-request-recovery.md | 4 ++-- docs/core-data-structures/core.md | 18 +----------------- docs/core-data-structures/llm-streaming.md | 6 ++++-- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/loop.ts | 4 +++- .../tests/contract-regressions.spec.ts | 2 +- .../agent-loop/tests/request-recovery.spec.ts | 4 ++-- packages/llm/llm-deepseek/src/adapter.ts | 6 +++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 4 ++-- packages/llm/llm-retry/README.md | 2 +- packages/llm/llm-retry/src/index.ts | 8 +++++--- packages/llm/llm-retry/tests/retry.spec.ts | 4 ++-- packages/llm/llm/src/adapter-failure.ts | 7 ++++--- packages/llm/llm/src/index.ts | 10 +++++----- packages/llm/llm/src/types.ts | 2 +- packages/llm/llm/tests/service.spec.ts | 7 ++++--- scripts/type-equiv.manifest.json | 1 - 17 files changed, 41 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index ad476b8b35..28de5eb97c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -29,7 +29,7 @@ interface LlmFailure { message: string code: string status?: number - retryAfterMs?: number + providerRetryAfterMs?: number requestId?: ProviderRequestId } ``` @@ -64,7 +64,7 @@ interface Config { The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. -For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid provider `retryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. +For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index d08297a751..8db120a963 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -224,23 +224,7 @@ interface GenerateOptions { } ``` -Why a model response stopped is a merge-extensible reason: - -```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ -interface LlmFailure { - /** Human-readable provider or transport failure. */ - readonly message: string - /** Stable provider-neutral machine-routing code. */ - readonly code: string - /** HTTP status observed at the provider boundary, when available. */ - readonly status?: number - /** Provider-requested delay in milliseconds, when valid and available. */ - readonly retryAfterMs?: number - /** Opaque provider-issued request identifier for diagnostics. */ - readonly requestId?: ProviderRequestId -} -``` +Why a model response stopped is a merge-extensible reason. Terminal provider failures carry the streaming contract's [`LlmFailure`](llm-streaming.md#llmfailure): ```ts type-equiv /** diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 1ecaa00943..674bff9dc8 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -31,7 +31,9 @@ type StreamChunk = } ``` -Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `retryAfterMs` is a validated positive delay observed at the provider boundary, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. +## `LlmFailure` + +Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. ```ts type-equiv /** Serializable provider-boundary facts; policy decides whether they are retryable. */ @@ -43,7 +45,7 @@ interface LlmFailure { /** HTTP status observed at the provider boundary, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ - readonly retryAfterMs?: number + readonly providerRetryAfterMs?: number /** Opaque provider-issued request identifier for diagnostics. */ readonly requestId?: ProviderRequestId } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e6c4cf05cb..c0b54d8444 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1186,7 +1186,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmFailure', - declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly retryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', + declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', }, { name: 'LlmModelInfo', diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 926674f866..c237e24fae 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -45,7 +45,9 @@ function finishError(finish: FinishReason): { error: RequestError; failure: LlmF const facts = finish.failure const error = new LlmError(facts.message, facts.code, { ...facts.status === undefined ? {} : { status: facts.status }, - ...facts.retryAfterMs === undefined ? {} : { retryAfterMs: facts.retryAfterMs }, + ...facts.providerRetryAfterMs === undefined + ? {} + : { providerRetryAfterMs: facts.providerRetryAfterMs }, ...facts.requestId === undefined ? {} : { requestId: facts.requestId }, }) return { error, failure: error.failure } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index f79f917281..e6d269d215 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -929,7 +929,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () message: 'provider 401', code: 'AUTH', status: 401, - retryAfterMs: 2_000, + providerRetryAfterMs: 2_000, requestId: ProviderRequestId('finish-request-1'), } const errorStream: StreamChunk[] = [ diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index 72688b96bc..039252a6f4 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -422,7 +422,7 @@ describe('agent post-step and request-error lifecycle', () => { it('passes structured facts beside the original Error and records them on exhaustion', async () => { const original = new LlmError('provider busy', 'RATE_LIMIT', { status: 429, - retryAfterMs: 2_000, + providerRetryAfterMs: 2_000, requestId: ProviderRequestId('req-9'), }) Object.freeze(original) @@ -448,7 +448,7 @@ describe('agent post-step and request-error lifecycle', () => { message: 'provider busy', code: 'RATE_LIMIT', status: 429, - retryAfterMs: 2_000, + providerRetryAfterMs: 2_000, requestId: ProviderRequestId('req-9'), }) expect(seenHistory).toEqual([]) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index b34cc49087..45bc229b7d 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -42,7 +42,7 @@ export interface DeepSeekAdapterOptions { export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' -function retryAfterMs(value: string | null): number | undefined { +function providerRetryAfterMs(value: string | null): number | undefined { if (value === null) return undefined if (/^\d+$/.test(value)) { const delay = Number(value) * 1_000 @@ -188,11 +188,11 @@ export class DeepSeekAdapter extends LlmAdapter { // Only swallow error-body parsing: the HTTP status still identifies the // failure, so malformed gateway JSON must not mask it. } - const delay = retryAfterMs(response.headers.get('retry-after')) + const delay = providerRetryAfterMs(response.headers.get('retry-after')) const id = requestId(response.headers) throw new LlmError(message, httpErrorCode(response.status, providerError), { status: response.status, - ...delay === undefined ? {} : { retryAfterMs: delay }, + ...delay === undefined ? {} : { providerRetryAfterMs: delay }, ...id === undefined ? {} : { requestId: id }, }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 2e23fd49f7..8cc02891a6 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -232,7 +232,7 @@ describe('DeepSeekAdapter against a mock server', () => { message: 'slow down', code: 'RATE_LIMIT', status: 429, - retryAfterMs: 2_000, + providerRetryAfterMs: 2_000, requestId: ProviderRequestId('req-429'), }) }) @@ -257,7 +257,7 @@ describe('DeepSeekAdapter against a mock server', () => { message: 'come back later', code: 'SERVER', status: 503, - retryAfterMs: 3_000, + providerRetryAfterMs: 3_000, requestId: ProviderRequestId('deepseek-503'), }, }) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index baebc2d0b3..f84fdba09a 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -2,7 +2,7 @@ Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid provider `retryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. +The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index d4c5b47b3d..4edf22d6f2 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -190,9 +190,11 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const retry = priorTransientFailures + 1 let delayMs: number - if (failure.retryAfterMs !== undefined && Number.isFinite(failure.retryAfterMs) && failure.retryAfterMs > 0) { - if (failure.retryAfterMs > resolved.maxDelayMs) return next() - delayMs = failure.retryAfterMs + if (failure.providerRetryAfterMs !== undefined + && Number.isFinite(failure.providerRetryAfterMs) + && failure.providerRetryAfterMs > 0) { + if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next() + delayMs = failure.providerRetryAfterMs } else { delayMs = localDelay(resolved, retry, random) } diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index bc10e28922..8e2f086e97 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -235,7 +235,7 @@ describe('bounded transient retry policy', () => { it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => { vi.useFakeTimers() const accepted = new ScriptedAdapter([ - new LlmError('wait', 'RATE_LIMIT', { retryAfterMs: 2_000 }), + new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }), textResponse('done'), ]) ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) @@ -250,7 +250,7 @@ describe('bounded transient retry policy', () => { await context.fiber.dispose() const rejected = new ScriptedAdapter([ - new LlmError('wait too long', 'RATE_LIMIT', { retryAfterMs: 10_001 }), + new LlmError('wait too long', 'RATE_LIMIT', { providerRetryAfterMs: 10_001 }), ]) ;({ ctx: context } = await harness(rejected)) const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' }) diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts index b2189fdaf9..390282327d 100644 --- a/packages/llm/llm/src/adapter-failure.ts +++ b/packages/llm/llm/src/adapter-failure.ts @@ -76,18 +76,19 @@ function failureSnapshot(value: unknown): LlmFailure | undefined { const message = candidate.message const code = candidate.code const status = candidate.status - const retryAfterMs = candidate.retryAfterMs + const providerRetryAfterMs = candidate.providerRetryAfterMs const requestId = candidate.requestId if (typeof message !== 'string' || message.length === 0 || typeof code !== 'string' || code.length === 0 || (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599)) - || (retryAfterMs !== undefined && (!Number.isFinite(retryAfterMs) || retryAfterMs <= 0)) + || (providerRetryAfterMs !== undefined + && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0)) || (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0))) return undefined return Object.freeze({ message, code, ...status === undefined ? {} : { status }, - ...retryAfterMs === undefined ? {} : { retryAfterMs }, + ...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs }, ...requestId === undefined ? {} : { requestId }, }) } catch (_sdkFailureGetter) { diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index dfebb059be..fb431a308c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -50,7 +50,7 @@ export interface LlmErrorOptions extends ErrorOptions { /** Valid HTTP status observed at the provider boundary. */ status?: number /** Positive finite provider-requested delay in milliseconds. */ - retryAfterMs?: number + providerRetryAfterMs?: number /** Non-empty opaque provider request id. */ requestId?: ProviderRequestId } @@ -75,9 +75,9 @@ export class LlmError extends HarnessError { && (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) { throw new Error('LlmError status must be an integer from 100 through 599') } - if (options?.retryAfterMs !== undefined - && (!Number.isFinite(options.retryAfterMs) || options.retryAfterMs <= 0)) { - throw new Error('LlmError retryAfterMs must be a positive finite number') + if (options?.providerRetryAfterMs !== undefined + && (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) { + throw new Error('LlmError providerRetryAfterMs must be a positive finite number') } if (options?.requestId !== undefined && (typeof options.requestId !== 'string' || options.requestId.length === 0)) { @@ -89,7 +89,7 @@ export class LlmError extends HarnessError { message, code, ...options?.status === undefined ? {} : { status: options.status }, - ...options?.retryAfterMs === undefined ? {} : { retryAfterMs: options.retryAfterMs }, + ...options?.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: options.providerRetryAfterMs }, ...options?.requestId === undefined ? {} : { requestId: options.requestId }, }) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index f8412aee2b..bbe2e09b59 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -16,7 +16,7 @@ export interface LlmFailure { /** HTTP status observed at the provider boundary, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ - readonly retryAfterMs?: number + readonly providerRetryAfterMs?: number /** Opaque provider-issued request identifier for diagnostics. */ readonly requestId?: ProviderRequestId } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index e491ad2dc8..6994ed5e7a 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -191,7 +191,7 @@ describe('LlmService', () => { it('keeps structured provider facts beside a frozen third-party Error', async () => { const original = new LlmError('provider busy', 'RATE_LIMIT', { status: 429, - retryAfterMs: 1_500, + providerRetryAfterMs: 1_500, requestId: ProviderRequestId('req-7'), }) Object.freeze(original) @@ -212,7 +212,7 @@ describe('LlmService', () => { message: 'provider busy', code: 'RATE_LIMIT', status: 429, - retryAfterMs: 1_500, + providerRetryAfterMs: 1_500, requestId: ProviderRequestId('req-7'), }) }) @@ -747,7 +747,8 @@ describe('LlmService', () => { it('rejects non-serializable structured failure facts at construction', () => { expect(() => new LlmError('busy', 'RATE_LIMIT', { status: 42 })).toThrow(/status/) - expect(() => new LlmError('busy', 'RATE_LIMIT', { retryAfterMs: Number.NaN })).toThrow(/retryAfterMs/) + expect(() => new LlmError('busy', 'RATE_LIMIT', { providerRetryAfterMs: Number.NaN })) + .toThrow(/providerRetryAfterMs/) expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: ProviderRequestId('') })).toThrow(/requestId/) expect(() => new LlmError(1 as never, 'RATE_LIMIT')).toThrow(/message/) expect(() => new LlmError('busy', 1 as never)).toThrow(/code/) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ccd573dd9f..b9fa1f4874 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -6,7 +6,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "AssistantProvenance", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "LlmFailure", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, From bdd0a05c4aa7397760750c36dafdc1b0975c07e1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:41:54 +0800 Subject: [PATCH 108/132] docs(agent-note): align goal rollup with implementation --- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../feature/2026-07-16-harness-level-loop.md | 12 ++++++------ .../feature/2026-07-16-harness-level-loop.zh.md | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 9501b344dd..19c946a6f6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 5ed9a08f3b80fe3ff8d87d90eed8c8af34179f95 -2026-07-16-harness-level-loop.zh.md: 7608f42517dad901a48e1bb1c1d1aa57f93c0374 +2026-07-16-harness-level-loop.md: f9f501d365d368cffdbc0f909db48f9d6ce926b5 +2026-07-16-harness-level-loop.zh.md: 1d09841e74530fbe4c9d86b7a481a95494890afe diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index 5ed9a08f3b..f9f501d365 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -35,8 +35,8 @@ Time-based `/loop` or scheduled execution is a third policy and is not implement | Package | Repository category | Owned structures and verbs | |---|---|---| -| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, `GoalPhase`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `markUsageLimited`, `markBudgetLimited`, `clear`, and `disarm` verbs. | -| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports. | +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`, domain service | Owns `GoalId`, compare-and-set `GoalRef`, `GoalSnapshot`, four-state `GoalPhase`, structured `GoalBlockReason`, process-local `GoalActivation`, replay folding, and `get`, `create`, `edit`, `pause`, `resume`, `complete`, `block`, `clear`, and `disarm` verbs. | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`, model-facing consumer | Registers exclusive `get_goal`, `create_goal`, and `update_goal`; authenticates live turn provenance and narrows autonomous-round authority to completion or blocking reports with machine-routable reason codes. | | `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`, continuation policy | Reserves, fences, admits, attributes, settles, cancels, and quiescently drains same-session goal rounds without importing the concrete loop. | | `@deepseek-ai/dsh-commands` | `packages/ui/commands/`, UI registry | Owns `CommandDefinition`, discovery, scoped registration, direct dispatch, `CommandResult`, and request cancellation for human-only commands. | | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`, human-command producer | Registers `/goal` status, creation, edit, pause, resume, and clear over the goal domain for TUI and ACP. | @@ -48,7 +48,7 @@ The detailed contracts live in the [goal-domain](2026-07-19-persisted-same-sessi One session has at most one current goal. Every non-clear mutation appends a full, versioned, model-visible goal snapshot through `Agent.inject()`; clear appends a revisioned tombstone. The session log is the only durable source of truth, so normal persistence, resume, compaction semantics, and `SessionStore.fork()` carry the goal without a second database or an artificial cancellation record. -Durable phases are `active`, `paused`, `blocked`, `usage-limited`, `budget-limited`, and `complete`. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. +Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed. This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption. @@ -62,7 +62,7 @@ The goal-round driver owns at most one pending reservation per exact live agent. Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round. -Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses; rate limiting records `usage-limited`; cap exhaustion records `budget-limited`; other errors, max-token stops, policy rejections, and unknown terminal results block for inspection. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`. +Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`. ### Human and model surfaces @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted. +TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted. ### Fresh-agent Ralph execution @@ -94,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s ### Verification -The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, command discovery in TUI/ACP, and ACP transcript isolation. Ralph's keyless real stack—worker-thread engine, spawn provider, structured-output runtime, and agent loop—covers distinct unseeded children, exact bounded handoff, completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and the assembled applications are pinned by keyless replay snapshots and built-binary tests. +The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package or echo-agent coverage. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index 7608f42517..1d09841e74 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -35,8 +35,8 @@ Status: implemented | 包 | 仓库类别 | 所属结构与动词 | |---|---|---| -| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、`GoalPhase`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`markUsageLimited`、`markBudgetLimited`、`clear` 与 `disarm` 动词。 | -| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到完成或阻塞报告。 | +| `@deepseek-ai/dsh-goal` | `packages/goal/goal/`,领域服务 | 拥有 `GoalId`、比较并交换 `GoalRef`、`GoalSnapshot`、四状态 `GoalPhase`、结构化 `GoalBlockReason`、进程本地 `GoalActivation`、重放折叠,以及 `get`、`create`、`edit`、`pause`、`resume`、`complete`、`block`、`clear` 与 `disarm` 动词。 | +| `@deepseek-ai/dsh-tool-goal` | `packages/goal/tool-goal/`,面向模型消费者 | 注册互斥的 `get_goal`、`create_goal` 与 `update_goal`;认证实时 Turn 来源,并把自治 Round 权限收窄到带机器可路由原因代码的完成或阻塞报告。 | | `@deepseek-ai/dsh-goal-session` | `packages/goal/goal-session/`,续行策略 | 在不导入具体 loop 的情况下,预留、设围栏、接纳、归属、结算、取消并静止排空同会话目标回合。 | | `@deepseek-ai/dsh-commands` | `packages/ui/commands/`,UI 注册表 | 拥有面向人类专用命令的 `CommandDefinition`、发现、作用域注册、直接分发、`CommandResult` 与请求取消。 | | `@deepseek-ai/dsh-command-goal` | `packages/goal/command-goal/`,人类命令生产方 | 为 TUI 和 ACP 注册构建在目标领域之上的 `/goal` 状态、创建、编辑、暂停、恢复与清除。 | @@ -48,7 +48,7 @@ Status: implemented 一个会话至多有一个当前目标。每次非清除变更都通过 `Agent.inject()` 追加一份完整、带版本且模型可见的目标快照;清除会追加带修订号的墓碑。会话日志是唯一持久事实来源,因此普通持久化、恢复、压缩语义与 `SessionStore.fork()` 会携带目标,无需第二个数据库或人为取消记录。 -持久阶段为 `active`、`paused`、`blocked`、`usage-limited`、`budget-limited` 与 `complete`。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 +持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 重放、驱动器替换和驱动器拆卸都会让目标保持未激活。 这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如“继续”、“恢复目标”或任何语言中的等价请求,会给运行时根 agent 的模型一个新 Turn;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类 Turn;提示策略让模型解释措辞在语义上是否授权创建或恢复。 @@ -62,7 +62,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for 只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。 -普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停;速率限制记录 `usage-limited`;上限耗尽记录 `budget-limited`;其他错误、max-token 停止、策略拒绝和未知终止结果会进入阻塞状态以供检查。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 +普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 ### 人类与模型表面 @@ -70,7 +70,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。 +TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 @@ -94,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 ### 验证 -六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、TUI/ACP 命令发现与 ACP 转录隔离。Ralph 的无密钥真实栈——工作线程引擎、spawn provider、结构化输出运行时与 agent loop——覆盖互不相同且无种子的子 agent、准确有界交接、完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,组装后应用由无密钥重放快照与构建后二进制测试固定。 +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖包级或 echo-agent 覆盖。 ## 考虑过的替代方案 From a93e9b86e7d51cac88ea10fa15f9378e49ecf7dc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:42:06 +0800 Subject: [PATCH 109/132] docs(testing): require real-example snapshots --- AGENTS.md | 4 ++-- docs/testing.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87f46c88a0..803ac5888f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,9 +119,9 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and echo-agent fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). -- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces, and schedule any missing harness support before implementation. +- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. diff --git a/docs/testing.md b/docs/testing.md index 84629aae45..6672bfc6f3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting an editor-facing transcript, headless event stream, or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or echo-agent compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation. From 671ef4d7eec6fa253e0e6a15297d3dd23fe67507 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:49:29 +0800 Subject: [PATCH 110/132] fix(goal): synchronize workspace lock entries --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f7a641df7..8e04aef04c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1034,9 +1037,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 73ad649c6aeb39cc768d2110f28e3811e69c4059 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:09:54 +0800 Subject: [PATCH 111/132] fix(goal): align session-stack lock importer --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f3251d26..2b4115f0a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,9 +514,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1012,6 +1009,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope From 4cadf096ce2e9792e931ca2cc143ae9e264d9380 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:26:04 +0800 Subject: [PATCH 112/132] Remove the stdio agent --- ...2026-06-20-extract-example-app-packages.md | 17 +- .../2026-06-20-package-hierarchy.md | 2 + ...t-variables-and-tool-guidance-ownership.md | 4 +- .../2026-07-08-tool-output-spill-files.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- .../feature/2026-06-25-ask-user-question.md | 6 +- .../feature/2026-07-06-explicit-tool-order.md | 2 +- ...6-07-09-bash-backed-grep-glob-discovery.md | 2 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 8 +- ...dedicated-full-screen-tui-front-door.zh.md | 8 +- .../2026-07-03-documentation-graph-atlas.md | 6 +- .../process/2026-07-06-node-engine-floor.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 + .../2026-07-20-remove-stdio-agent.i18n.yaml | 6 + .../2026-07-20-remove-stdio-agent.md | 44 + .../2026-07-20-remove-stdio-agent.zh.md | 44 + ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 2 +- ...6-07-18-tui-terminal-state-snapshots.zh.md | 2 +- ...026-07-14-sdk-developer-projects.i18n.yaml | 4 +- .../2026-07-14-sdk-developer-projects.md | 12 +- .../2026-07-14-sdk-developer-projects.zh.md | 12 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 2 +- AGENTS.md | 17 +- README.i18n.yaml | 4 +- README.md | 5 +- README.zh.md | 5 +- docs/architecture.md | 2 +- docs/capability-seams.md | 13 +- docs/config-catalog.md | 124 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/user-interaction.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 14 +- docs/development.zh.md | 14 +- docs/event-producer-consumer.md | 12 +- docs/graph-atlas.md | 1 - docs/i18n/translation-prompt.md | 6 +- docs/module-graph.md | 36 +- .../0001-acp-default-export-drops-inject.md | 2 +- docs/tool-catalog.md | 4 +- .../develop/practice/llm-adapter.i18n.yaml | 4 +- docs/user/develop/practice/llm-adapter.md | 6 +- docs/user/develop/practice/llm-adapter.zh.md | 6 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 12 +- docs/user/guide/config.zh.md | 12 +- docs/user/guide/index.i18n.yaml | 4 +- docs/user/guide/index.md | 6 +- docs/user/guide/index.zh.md | 6 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 71 +- docs/user/guide/quickstart.zh.md | 75 +- examples/AGENTS.md | 2 +- examples/README.md | 24 +- examples/cordis-agent/README.md | 2 +- examples/cordis-agent/composition.md | 12 +- examples/cordis-agent/cordis.yml | 6 +- .../cordis-agent/tests/keyless-smoke.e2e.ts | 22 +- examples/echo-agent/README.md | 33 +- examples/echo-agent/composition.md | 17 +- examples/echo-agent/cordis.yml | 25 +- examples/echo-agent/package.json | 2 +- examples/echo-agent/tests/echo.e2e.ts | 38 +- .../fixtures/context/time-context/cordis.yml | 5 +- .../fixtures/context/time-context/driver.ts | 16 + examples/headless-agent/README.md | 2 +- .../tests/code-mode.e2e.ts | 0 .../tests/coding-task.e2e.ts | 0 .../tests/compaction.e2e.ts | 0 .../tests/full-loop.e2e.ts | 0 .../tests/harness.ts | 2 +- .../tests/resume.e2e.ts | 0 .../tests/todo-write.e2e.ts | 0 examples/package.json | 3 +- examples/repl-agent/README.md | 68 -- examples/repl-agent/code-mode.cordis.yml | 33 - examples/repl-agent/composition.md | 91 -- examples/repl-agent/cordis.yml | 143 --- examples/repl-agent/package.json | 7 - .../tests/code-mode-keyless-smoke.e2e.ts | 27 - .../repl-agent/tests/keyless-smoke.e2e.ts | 28 - examples/tui-agent/README.md | 12 +- examples/tui-agent/code-mode.cordis.yml | 19 +- examples/tui-agent/composition.md | 77 +- examples/tui-agent/cordis.yml | 135 ++- .../tests/fixtures/tui-scripted.cordis.yml | 8 +- examples/tui-agent/tests/pty-harness.ts | 127 ++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 178 +-- knip.json | 8 +- package.json | 7 +- packages/README.md | 2 +- .../time-context/tests/time-context.e2e.ts | 102 +- packages/cordis/tool-cordis/src/sandbox.ts | 2 +- .../tests/config-session-id.spec.ts | 32 +- packages/examples/README.md | 4 +- packages/examples/acp-demo/README.md | 4 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 4 +- packages/examples/agent-spine-demo/README.md | 4 +- packages/examples/cli-demo/README.md | 6 +- packages/examples/stdio-demo/README.md | 112 -- packages/examples/stdio-demo/src/index.ts | 181 --- .../stdio-demo/tests/built-bin.e2e.ts | 218 ---- .../stdio-demo/tests/stdio-agent.spec.ts | 293 ----- packages/examples/tui-demo/README.md | 101 ++ .../{stdio-demo => tui-demo}/package.json | 10 +- .../{stdio-demo => tui-demo}/src/bin.ts | 8 +- packages/examples/tui-demo/src/index.ts | 121 ++ .../examples/tui-demo/tests/tui-agent.spec.ts | 119 ++ .../{stdio-demo => tui-demo}/tsconfig.json | 6 - .../{stdio-demo => tui-demo}/tsdown.config.ts | 2 +- packages/sdk/create-sdk/src/args.ts | 2 +- .../sdk/create-sdk/src/create-questions.ts | 4 +- .../src/templates/assets/usage.txt.tpl | 2 +- .../sdk/create-sdk/tests/create.snapshot.ts | 4 +- packages/sdk/create-sdk/tests/create.spec.ts | 8 +- .../sdk/helper/src/features/builtin/app.ts | 22 +- .../sdk/helper/src/features/builtin/index.ts | 2 +- .../sdk/helper/src/features/define-feature.ts | 2 +- packages/sdk/helper/src/features/feature.ts | 2 +- .../src/project/project-edit-session.ts | 2 +- .../sdk/helper/src/project/sdk-project.ts | 4 +- packages/sdk/helper/src/project/types.ts | 2 +- .../helper/src/templates/assets/README.md.tpl | 2 +- .../helper/src/templates/assets/index.ts.tpl | 10 +- .../helper/src/templates/project-template.ts | 6 +- packages/sdk/helper/tests/documents.spec.ts | 2 +- packages/sdk/helper/tests/project.spec.ts | 52 +- packages/sdk/helper/tests/questions.spec.ts | 2 +- .../sdk/scripts/src/config/config-workflow.ts | 2 +- .../__snapshots__/config.snapshot.ts.snap | 4 +- packages/sdk/scripts/tests/config.snapshot.ts | 2 +- packages/sdk/scripts/tests/scripts.spec.ts | 8 +- packages/support/README.md | 2 +- .../loader-smoke/tests/example-launch.spec.ts | 6 +- packages/todo/README.md | 2 +- packages/todo/tool-todo/README.md | 2 +- packages/ui/README.md | 5 +- packages/ui/acp/README.md | 2 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/src/index.ts | 2 +- packages/ui/stdio/README.md | 58 - packages/ui/stdio/package.json | 49 - packages/ui/stdio/src/index.ts | 471 -------- packages/ui/stdio/tests/plugin-shape.spec.ts | 19 - packages/ui/stdio/tests/readline.spec.ts | 54 - packages/ui/stdio/tests/stdio.spec.ts | 1044 ----------------- packages/ui/stdio/tsconfig.json | 33 - packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 4 +- packages/ui/user-interaction/README.md | 2 +- pnpm-lock.yaml | 51 +- scripts/demo-code-mode.mjs | 13 +- scripts/gen-doc-graphs.ts | 35 +- scripts/gen-tool-catalog.ts | 2 +- scripts/run-gates.ts | 13 +- skills/create-dsh-sdk-project/SKILL.md | 2 +- tsconfig.build.json | 3 +- tsconfig.json | 3 +- 162 files changed, 1301 insertions(+), 3920 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md create mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md create mode 100644 examples/echo-agent/tests/fixtures/context/time-context/driver.ts rename examples/{repl-agent => headless-agent}/tests/code-mode.e2e.ts (100%) rename examples/{repl-agent => headless-agent}/tests/coding-task.e2e.ts (100%) rename examples/{repl-agent => headless-agent}/tests/compaction.e2e.ts (100%) rename examples/{repl-agent => headless-agent}/tests/full-loop.e2e.ts (100%) rename examples/{repl-agent => headless-agent}/tests/harness.ts (98%) rename examples/{repl-agent => headless-agent}/tests/resume.e2e.ts (100%) rename examples/{repl-agent => headless-agent}/tests/todo-write.e2e.ts (100%) delete mode 100644 examples/repl-agent/README.md delete mode 100644 examples/repl-agent/code-mode.cordis.yml delete mode 100644 examples/repl-agent/composition.md delete mode 100644 examples/repl-agent/cordis.yml delete mode 100644 examples/repl-agent/package.json delete mode 100644 examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts delete mode 100644 examples/repl-agent/tests/keyless-smoke.e2e.ts create mode 100644 examples/tui-agent/tests/pty-harness.ts delete mode 100644 packages/examples/stdio-demo/README.md delete mode 100644 packages/examples/stdio-demo/src/index.ts delete mode 100644 packages/examples/stdio-demo/tests/built-bin.e2e.ts delete mode 100644 packages/examples/stdio-demo/tests/stdio-agent.spec.ts create mode 100644 packages/examples/tui-demo/README.md rename packages/examples/{stdio-demo => tui-demo}/package.json (84%) rename packages/examples/{stdio-demo => tui-demo}/src/bin.ts (66%) create mode 100644 packages/examples/tui-demo/src/index.ts create mode 100644 packages/examples/tui-demo/tests/tui-agent.spec.ts rename packages/examples/{stdio-demo => tui-demo}/tsconfig.json (88%) rename packages/examples/{stdio-demo => tui-demo}/tsdown.config.ts (87%) delete mode 100644 packages/ui/stdio/README.md delete mode 100644 packages/ui/stdio/package.json delete mode 100644 packages/ui/stdio/src/index.ts delete mode 100644 packages/ui/stdio/tests/plugin-shape.spec.ts delete mode 100644 packages/ui/stdio/tests/readline.spec.ts delete mode 100644 packages/ui/stdio/tests/stdio.spec.ts delete mode 100644 packages/ui/stdio/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md index 3fa7227d04..f9e4217466 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -6,29 +6,29 @@ Status: implemented An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. -The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through `session/new`; stdio requires a console logger and a pre-created `main`. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. +The leaf configs also owned coupled front doors. ACP requires stdout purity and creates agents through `session/new`; terminal and Headless apps pre-create `main` but have different process I/O contracts. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. ## Decision Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). - **`@deepseek-ai/dsh-agent-spine-demo`** ([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. -- **`@deepseek-ai/dsh-stdio-demo`** ([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo)) and **`@deepseek-ai/dsh-acp-demo`** ([packages/examples/acp-demo](../../../../packages/examples/acp-demo)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. -- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-demo` / `dsh-acp-demo`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-demo ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. -- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). -- **echo-agent folds onto `dsh-stdio-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. +- **`@deepseek-ai/dsh-tui-demo`**, **`@deepseek-ai/dsh-cli-demo`**, and **`@deepseek-ai/dsh-acp-demo`** bake in their process roles. TUI includes the full-screen UI and a pre-created `main`; Headless includes the one-shot driver and a pre-created `main`; ACP includes the bridge and no pre-created agent. All three include JSONL persistence and omit stdout loggers. +- **`start.ts` is gone.** Each app package exposes a bin; the `demo:*` scripts invoke it. Loader boot, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); the thin self-executing entries are driven by keyless Loader-path tests. +- **Each leaf `cordis.yml` collapses** to backends, optional product tools, and one app entry carrying the app config. TUI and Headless route model/session choices onto a pre-created agent; ACP routes the initial provider/model onto its bridge. +- **echo-agent loads `dsh-cli-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` at the leaf. `mock-llm.ts` and `echo-tool.ts` stay as example-local teaching plugins. - **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`. `bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. ### Amendment on implementation: `hmr` stays a leaf entry -The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-demo` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: +The proposal listed `hmr` among the interactive app's baked-in front-door cluster. Validating against the code, baking `hmr` into the app package fights Cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead: 1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier. 2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function. -Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger is — a stray `hmr` in the ACP config would not corrupt the JSON-RPC frames — so leaving it at the leaf costs none of the safety the coupling argument is about. The **logger** (the real coupling) stays baked in: the stdio app includes it, the ACP app omits it. +Crucially, `hmr` is not a stdout-purity footgun: a stray entry in the ACP config does not corrupt JSON-RPC frames. Every shipped app omits a stdout console logger; the app or protocol driver alone owns stdout. ## Alternatives considered @@ -39,7 +39,7 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Verification - Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone. -- `demo:echo`, `demo:repl`, and `demo:acp` invoke the app-package bins. +- `demo:echo`, `demo:tui`, `demo:headless`, and `demo:acp` invoke the app-package bins. - Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). - The ACP replay transcript remains unchanged because the plugin set and load order did not change. @@ -53,3 +53,4 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a - Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted. - Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). +- The later [remove-stdio-agent decision](../simplification/2026-07-20-remove-stdio-agent.md) owns the final TUI/Headless split and removal of the line-oriented app. diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index d853696226..800e49cf3c 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -2,6 +2,8 @@ Status: implemented +The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [remove-stdio-agent](../simplification/2026-07-20-remove-stdio-agent.md) decision subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here. + ## Problem `packages/` was flat: 18 packages all sat at `packages//`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 631e5b570c..cdd37091a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -8,7 +8,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. @@ -56,7 +56,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Shipped invariants -- The repl-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- The tui-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. - Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 1255dc7328..a9197de179 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -162,7 +162,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection. - `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`). - `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result. -- The `repl-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). +- The `tui-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader/PTY smoke exercises the real load path (the namespace-plugin export shape + `inject`). ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 30a0c83a31..99313866ed 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -116,7 +116,7 @@ Two failure paths, both documented: - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. - **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. `dsh-invariants` treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. -- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. +- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md index 4227341aec..e1c5d03c08 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md +++ b/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md @@ -20,7 +20,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway ## UI mappings -`dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. +`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time. `dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. @@ -42,8 +42,8 @@ ACP elicitation is currently marked unstable in the SDK. The fallback is still s The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it. -`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `stdio-agent` opts into the seam, its readline provider, and the model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. +`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests. ## Testing -Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-demo` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. +Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. diff --git a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md index d46579dee1..c78126e92a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md @@ -21,7 +21,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). -Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-demo`, `dsh-acp-demo`) accept the key and forward it through `dsh-agent-spine-demo` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. +Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the TUI, Headless, and ACP app configs accept the key and forward it through `dsh-agent-spine-demo` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index 60a5e3e3a0..64fa232831 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -155,7 +155,7 @@ If the complete logical result fits under the inline cap, no formatted spill art - The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display. - The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model. - Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`. -- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the conditional tool plugin (the acp-agent tree waits on the snapshot re-record above); the fs group README records the `rg` availability and co-located bash/filesystem deployment requirements. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the tui-agent example ships the conditional tool plugin (the acp-agent tree waits on the snapshot re-record above); the fs group README records the `rg` availability and co-located bash/filesystem deployment requirements. ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 34c342ffd3..f1fc99ade2 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: 178b5ea44be67f820a8ea7fed8acb987dffb3f80 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: ac055bad1b7a692c7a980430fdbd1e34737a9994 +2026-07-17-dedicated-full-screen-tui-front-door.md: c7bd01011121f04683afde1d05b046970e497a71 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5de443f367725a116e70d368657562b6732904e7 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 178b5ea44b..c7bd010111 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -6,7 +6,7 @@ English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) ## Problem -The line-oriented `@deepseek-ai/dsh-stdio` front door works in pipes and ordinary terminals, but a full-screen coding interface must own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin couples the pipe-safe path to a TTY-only lifecycle and makes it unclear which terminal behavior a composition selects. +At the time this front door was introduced, the line-oriented agent handled pipes and ordinary terminals, but a full-screen coding interface had to own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin would have coupled a stream-oriented path to a TTY-only lifecycle. The later [remove-stdio-agent decision](../simplification/2026-07-20-remove-stdio-agent.md) removes that redundant line agent; this Note continues to own the TUI design. The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `repl-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the repl-agent backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. +The app layer has one terminal front door. `@deepseek-ai/dsh-tui-demo` mounts the TUI before the configured agent, and `examples/tui-agent` owns the interactive coding composition and Code Mode overlay directly. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate editor protocol. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. @@ -42,7 +42,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t ## Consequences -- Interactive terminal work gains a stateful Markdown, card, plan, and question interface without changing the line-oriented protocol used by pipes and automation. -- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments select `@deepseek-ai/dsh-stdio` at composition time. +- Interactive terminal work has a stateful Markdown, card, plan, and question interface with no second terminal protocol to keep aligned. +- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index ac055bad1b..5de443f367 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -逐行输出的 `@deepseek-ai/dsh-stdio` 入口适用于管道和普通终端,但全屏编码界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使管道安全路径依赖仅适用于 TTY 的生命周期,也使组合无法明确表达所选终端行为。 +在本入口引入时,面向行的 agent 负责 pipe 与普通终端,但全屏 coding 界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使面向 stream 的路径依赖仅适用于 TTY 的生命周期。后续的[移除 stdio agent 决策](../simplification/2026-07-20-remove-stdio-agent.md)移除了这一重复的面向行 agent;本 Note 继续负责 TUI 设计。 交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`repl-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 repl-agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 +应用组合层只有一个终端入口。`@deepseek-ai/dsh-tui-demo` 在已配置 agent 之前挂载 TUI,`examples/tui-agent` 直接拥有交互式 coding 组装及其 Code Mode overlay。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的编辑器协议。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 @@ -42,7 +42,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 ## 后果 -- 交互式终端获得带状态的 Markdown、卡片、计划和提问界面,同时不会改变管道与自动化使用的逐行协议。 -- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署在组合时选择 `@deepseek-ai/dsh-stdio`。 +- 交互式终端拥有带状态的 Markdown、卡片、计划和提问界面,无需再对齐第二套终端协议。 +- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md index 4f3fafe59d..cff6a4f17e 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -26,7 +26,7 @@ Every graph page declares one maintenance mode: ### First shipped index -The first index links ten relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. +The index links twelve relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. | Graph | Maintenance mode | Source of truth | |---|---|---| @@ -34,7 +34,9 @@ The first index links ten relationship surfaces. Package topology and tool-packa | [tool schema catalog and package map](../../../../docs/tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | | [capability seams and core services](../../../../docs/capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | | [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | -| [repl-agent app composition](../../../../examples/repl-agent/composition.md) | hybrid generated | `examples/repl-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [tui-agent app composition](../../../../examples/tui-agent/composition.md) | hybrid generated | `examples/tui-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [headless-agent app composition](../../../../examples/headless-agent/composition.md) | hybrid generated | `examples/headless-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [cordis-agent app composition](../../../../examples/cordis-agent/composition.md) | hybrid generated | `examples/cordis-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [acp-agent app composition](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [event producer/consumer matrix](../../../../docs/event-producer-consumer.md) | hybrid generated | Cordis event declarations, AST-scanned `ctx.on/emit/parallel/serial/waterfall` sites, and explicit dynamic dispatch overrides | | [agent turn and step lifecycle](../../../../docs/agent-lifecycle.md) | curated | architecture.md loop lifecycle, Cordis catalog links, and session event semantics | diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index 59f4c347eb..4609ed505c 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -13,7 +13,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibili Two Node features gate the source runtime: - **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. -- **Native TypeScript type-stripping** — the `packages/examples/stdio-demo/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. +- **Native TypeScript type-stripping** — the built-mode `examples/echo-agent/tests/echo.e2e.ts` smoke boots `dsh-cli-demo`'s published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index bde3efcc75..284f33ea18 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -2,6 +2,8 @@ Status: implemented +The later [remove-stdio-agent decision](2026-07-20-remove-stdio-agent.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. + ## Problem The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml new file mode 100644 index 0000000000..efa382c208 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-remove-stdio-agent.md: e4dcd371490e0810900134ab9a05f6f894ca06d0 +2026-07-20-remove-stdio-agent.zh.md: 9f0c2348de27b9c724e6f656881ec316bf48005f diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md new file mode 100644 index 0000000000..e4dcd37149 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md @@ -0,0 +1,44 @@ +# Agent Note: Remove the line-oriented stdio agent + +Status: implemented + +English | [中文](2026-07-20-remove-stdio-agent.zh.md) + +## Problem + +DeepSeek Harness had two terminal agents after the full-screen TUI shipped. `@deepseek-ai/dsh-tui` owned the interactive coding experience, while `@deepseek-ai/dsh-stdio` retained a line-oriented multi-turn chat protocol for ordinary streams. The latter was no longer a distinct product need: interactive users use the TUI, and scripts need a bounded Headless task with explicit output and exit semantics rather than prompts mixed with model and tool output. + +The redundant surface extended beyond one UI plugin. `@deepseek-ai/dsh-stdio-demo` selected between two terminal modes, `examples/repl-agent` owned a second copy of the coding composition, `demo:repl` exposed it, Loader and built-bin tests drove its prompt protocol, and the SDK generator offered a `stdio` interface that could create new users of the obsolete package. Keeping any of those paths would preserve the line agent indirectly. + +Standard input and output are also used as transport by ACP, the SDK JSON-RPC bridge, subprocesses, and test fixtures. Those byte channels are protocol boundaries, not the line-oriented agent, so removing every generic use of process streams would conflate unrelated designs. + +## Decision + +The line-oriented agent is removed without a compatibility package or mode alias. The `packages/ui/stdio` plugin, `@deepseek-ai/dsh-stdio-demo` package identity, `examples/repl-agent` leaf, `demo:repl` command, prompt/render tests, and supporting manifest, catalog, graph, and documentation entries are deleted. + +The two remaining application roles are explicit: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) is the only terminal-interactive app. `examples/tui-agent` owns the complete coding composition and its Code Mode overlay directly; it no longer includes or patches another terminal leaf. +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition and generic real-agent e2e suites, while `examples/echo-agent` supplies the keyless mock task and CI smoke. + +The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and continue to create or resume one exact session. No old option is accepted because the repository is pre-release and has no compatibility promise. + +ACP and JSON-RPC retain their stdio transports. Child-process `stdio` settings and stream-reading APIs also remain where they describe operating-system I/O rather than the removed agent. + +## Verification + +TUI Loader coverage runs the real app under a pseudo-terminal in both source and built modes. Headless Loader coverage proves the mock tool round trip, multi-turn test drivers exercise a single app-owned agent without a UI protocol, and the CLI built-bin suite pins text, JSON, stream-JSON, persistence, failure, and signal behavior. Generated package/config/module graphs reject stale package references. + +## Alternatives considered + +- **Keep the line agent only for pipes** — rejected because Headless already has a clearer bounded-task contract, format-pure stdout, durable completion, and process exit status. +- **Keep the package as a compatibility wrapper over Headless** — rejected because a multi-turn prompt protocol cannot honestly preserve its behavior by delegating to a one-shot CLI, and the pre-release policy favors the correct public surface. +- **Let the TUI fall back when streams are not TTYs** — rejected because silent interface changes hide deployment mistakes; the TUI fails loud and callers select Headless explicitly. +- **Remove every use of the term or mechanism stdio** — rejected because ACP and JSON-RPC intentionally use standard I/O as a framed transport and do not expose the removed line agent. + +## Consequences + +- Terminal interaction has one owner, one app package, one coding leaf, and one test strategy. +- Automation has an explicit task/result contract rather than prompt parsing or EOF-driven conversation control. +- Existing line-agent configurations and SDK `--interface=stdio` invocations fail instead of being translated. +- The TUI requires a TTY pair; non-interactive environments use Headless, ACP, or JSON-RPC according to their protocol needs. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md new file mode 100644 index 0000000000..9f0c2348de --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 移除面向行的 stdio agent + +Status: implemented + +[English](2026-07-20-remove-stdio-agent.md) | 中文 + +## 问题 + +全屏 TUI 交付后,DeepSeek Harness 同时存在两个终端 agent。`@deepseek-ai/dsh-tui` 负责交互式 coding 体验,而 `@deepseek-ai/dsh-stdio` 仍为普通 stream 保留面向行的多轮聊天协议。后者已不再对应独立的产品需求:交互用户使用 TUI;脚本需要的是具有明确输出和退出语义的有界 Headless 任务,而不是与模型和工具输出混在一起的提示符。 + +重复 surface 不只涉及一个 UI 插件。`@deepseek-ai/dsh-stdio-demo` 在两种终端模式间选择,`examples/repl-agent` 维护第二份 coding 组装,`demo:repl` 对外暴露它,Loader 与 built-bin 测试驱动其提示符协议,SDK 生成器还提供可以创建旧包新用户的 `stdio` interface。保留其中任何路径,都会间接保留面向行的 agent。 + +ACP、SDK JSON-RPC bridge、子进程和测试 fixture 同样使用标准输入输出作为 transport。这些字节通道是协议边界,并不是面向行的 agent;因此,删除所有通用进程 stream 用法会混淆彼此无关的设计。 + +## 决策 + +移除面向行的 agent,不提供兼容 package 或 mode alias。删除 `packages/ui/stdio` 插件、`@deepseek-ai/dsh-stdio-demo` package identity、`examples/repl-agent` 叶节点、`demo:repl` 命令、提示符/渲染测试,以及相关 manifest、catalog、graph 和文档条目。 + +保留的两个应用角色均改为显式选择: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 是唯一的终端交互式 app。`examples/tui-agent` 直接拥有完整 coding 组装及其 Code Mode overlay,不再 include 或 patch 另一个终端叶节点。 +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次组装和通用真实 agent e2e suite,`examples/echo-agent` 则提供 keyless mock 任务与 CI smoke。 + +SDK project model 与 create/config workflow 将 `stdio` run-interface 选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并继续创建或恢复一个确切 session。仓库处于 pre-release 阶段且没有兼容性承诺,因此不会接受旧选项。 + +ACP 和 JSON-RPC 保留各自的 stdio transport。描述操作系统 I/O 而非已移除 agent 的子进程 `stdio` 设置与 stream 读取 API 也继续保留。 + +## 验证 + +TUI Loader 覆盖在 source 与 built 两种模式下通过伪终端运行真实 app。Headless Loader 覆盖验证 mock 工具往返;多轮测试 driver 在没有 UI 协议的情况下驱动同一个 app-owned agent;CLI built-bin suite 固定 text、JSON、stream-JSON、持久化、失败和 signal 行为。生成的 package/config/module graph 会拒绝陈旧的 package 引用。 + +## 曾考虑的替代方案 + +- **仅为 pipe 保留面向行的 agent**:不予采纳,因为 Headless 已提供更清晰的有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 +- **保留 package,并将其作为 Headless 的兼容 wrapper**:不予采纳,因为多轮提示符协议无法通过委托给单次 CLI 来诚实地保持行为,而且 pre-release 策略优先选择正确的公开 surface。 +- **让 TUI 在 stream 不是 TTY 时回退**:不予采纳,因为静默切换 interface 会掩盖部署错误;TUI 会快速失败,由调用方显式选择 Headless。 +- **移除 stdio 这个术语或机制的所有用法**:不予采纳,因为 ACP 与 JSON-RPC 有意使用标准 I/O 作为分帧 transport,并不暴露已移除的面向行 agent。 + +## 后果 + +- 终端交互只有一个 owner、一个 app package、一个 coding 叶节点和一套测试策略。 +- 自动化使用显式 task/result 契约,不再解析提示符或通过 EOF 控制对话。 +- 现有面向行的 agent 配置和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 +- TUI 要求成对的 TTY;非交互环境根据协议需要使用 Headless、ACP 或 JSON-RPC。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index c208a1e553..c157853318 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-18-tui-terminal-state-snapshots.md: 192e872ab63cf4ff8a121ea0a2ee9345379cfa26 -2026-07-18-tui-terminal-state-snapshots.zh.md: 9766a8087632daa1be0dcfb191696dbad354ff68 +2026-07-18-tui-terminal-state-snapshots.md: 7277e6be6d88a73ea3abbd277e314715c8abc050 +2026-07-18-tui-terminal-state-snapshots.zh.md: 068a8b8a7f469f40492924bf94757bc0e25c3404 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 192e872ab6..7277e6be6d 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the readline `repl-agent` and `acp-agent` leaves. It reuses the repl-agent backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. +The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [line-agent removal](../simplification/2026-07-20-remove-stdio-agent.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index 9766a80876..068a8b8a7f 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `repl-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 repl-agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[面向行 agent 的移除决策](../simplification/2026-07-20-remove-stdio-agent.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml index c876ddc68f..f64160a5a0 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-sdk-developer-projects.md: 1be9abcad1e51a1b9a1406f21ce60073427576e0 -2026-07-14-sdk-developer-projects.zh.md: a8ba1d658f78484a46a7148a4e2ff1b073a3e9f2 +2026-07-14-sdk-developer-projects.md: aa5cf64d7dd33dea229d74c2ae45a9244ee70e3c +2026-07-14-sdk-developer-projects.zh.md: 8f7d1de5b16f38019c802f07eda701cee72deb4f diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md index 1be9abcad1..aa5cf64d7d 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md @@ -44,7 +44,7 @@ The table is the developer-visible support set for this phase. A `required` feat | Feature | Create state | Feature options | Constraints and relationships | |---|---|---|---| | `provider` | required | `deepseek` (default) / `custom` | DeepSeek collects an API key; custom also collects a base URL, and a CLI option may override the model name | -| `app` | required | `stdio` (default) / `acp` / `embed` | Selects the run interface | +| `app` | required | `tui` (default) / `acp` / `embed` | Selects the run interface | | `spine` | required | `default` | Timer, the LLM seam, session storage, system prompt, the tool registry, the agent registry, and the agent loop | | `bash` | required | `local` (default) / `sandbox` | The two feature options are exclusive and independent of the run interface, and both install the model-facing bash tool; sandbox installs the local sandbox provider and sandboxed bash backend | | `persistence` | required | `jsonl` (default) / `sqlite` | Every project selects exactly one persistence backend | @@ -59,9 +59,9 @@ The table is the developer-visible support set for this phase. A `required` feat | `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file | | `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders | | `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets | -| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `stdio` can select it because those two feature options provide the injected user-interaction service | +| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `tui` can select it because those two feature options provide the injected user-interaction service | -Both `bash` feature options apply to ACP, stdio, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`: +Both `bash` feature options apply to ACP, TUI, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`: ```yaml - id: bash @@ -72,11 +72,11 @@ Both `bash` feature options apply to ACP, stdio, and embed and are not selected # workspaceRoot: !!js process.cwd() ``` -Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `stdio-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly. +Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `tui-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly. ## Generated project -With default answers, an npm project uses the DeepSeek provider, the stdio interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is: +With default answers, an npm project uses the DeepSeek provider, the TUI interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is: ```text my-agent/ @@ -106,7 +106,7 @@ Generated `package.json` provides the following scripts. `dev`, `build`, `start` `dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`. -- Stdio projects pass the selected model through `--model=` and create or resume an agent according to optional `--resume=`; +- TUI projects pass the selected model through `--model=` and create or resume an agent according to optional `--resume=`; - ACP uses protocol `session/load` - Embed uses the model written into the generated code. diff --git a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md index a8ba1d658f..8f7d1de5b1 100644 --- a/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md +++ b/.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.zh.md @@ -44,7 +44,7 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl | 功能 | create 状态 | 功能选项 | 限制与关系 | |---|---|---|---| | `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API key;custom 另收集 base URL,模型名可由 CLI 参数覆盖 | -| `app` | required | `stdio`(默认)/ `acp` / `embed` | 选择运行接口 | +| `app` | required | `tui`(默认)/ `acp` / `embed` | 选择运行接口 | | `spine` | required | `default` | timer、LLM seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop | | `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具;sandbox 安装本地沙箱提供方和沙箱 bash 后端 | | `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 | @@ -59,9 +59,9 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl | `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 | | `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 | | `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 | -| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/stdio 两个功能选项提供,因此仅这两个接口可选 | +| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/tui 两个功能选项提供,因此仅这两个接口可选 | -`bash` 的两个功能选项都适用于 ACP、stdio 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: +`bash` 的两个功能选项都适用于 ACP、TUI 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`: ```yaml - id: bash @@ -72,11 +72,11 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl # workspaceRoot: !!js process.cwd() ``` -功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`stdio-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。 +功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`tui-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。 ## 生成工程 -使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 stdio,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: +使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 TUI,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为: ```text my-agent/ @@ -106,7 +106,7 @@ my-agent/ `dsh-sdk start` 与 `dsh-sdk dev` 可以接收模块 target,并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`,bare flag 转换为 `true`,`--no-*` 转换为 `false`。 -- stdio 工程通过 `--model=` 传入所选 model,并根据可选的 `--resume=` 创建或恢复 agent; +- TUI 工程通过 `--model=` 传入所选 model,并根据可选的 `--resume=` 创建或恢复 agent; - acp 使用协议 `session/load` - embed 使用生成代码中的 model。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 5c51209113..84b219c93b 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -54,7 +54,7 @@ pnpm run test:snapshot Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. ```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/echo-agent/tests/echo.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts ``` Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. diff --git a/AGENTS.md b/AGENTS.md index 24a138e396..12461b55ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + stdio/CLI/ACP/JSON-RPC bins) leaves load + ui/ ACP/TUI/JSON-RPC bridges; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -58,9 +58,8 @@ pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json pnpm run website:build # VitePress build (doubles as the site's dead-link check) -pnpm run demo:echo # mock-model REPL, no key needed -pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot agent (needs DEEPSEEK_API_KEY) +pnpm run demo:echo "task" # mock-model headless agent, no key needed +pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) @@ -86,12 +85,12 @@ pnpm run website:build pnpm run verify-module-graph pnpm run build pnpm run hygiene -out=$(printf 'echo ci smoke\n' | pnpm run demo:echo 2>&1) -printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' -printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' +out=$(pnpm run demo:echo --output-format stream-json -- "echo ci smoke" 2>&1) +printf '%s\n' "$out" | grep -q '"type":"tool/call"' +printf '%s\n' "$out" | grep -q 'ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/echo-agent/tests/echo.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/README.i18n.yaml b/README.i18n.yaml index 64e212ff3a..ad069ecd3e 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: ef9a3a8832d1eaa35ec5f0fed1780ab27e8ff37c -README.zh.md: a30d6db4b04f23559c36a7aba80b4feb2962a1c6 +README.md: 4ce9391e9286391a601d59d8401870c9ca8c79f3 +README.zh.md: c4f996762b36d2ebe00cd256e2f18b0a62550ba8 diff --git a/README.md b/README.md index ef9a3a8832..4ce9391e92 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # keyless mock-model REPL -pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:echo "task" # keyless mock-model headless agent pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index a30d6db4b0..c4f996762b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,10 +11,9 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # keyless mock-model REPL -pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:echo "task" # keyless mock-model headless agent pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/architecture.md b/docs/architecture.md index bab5ddee73..2e37f72c56 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,7 +157,7 @@ Some seams bend the template deliberately: LLM combines interface and consumer b ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-tui-demo` owns the interactive full-screen terminal; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a7ec9b638b..109d8ec441 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,11 +47,12 @@ flowchart LR pkg_tool_todo["tool-todo"] pkg_user_interaction["user-interaction"] svc_userInteraction["ctx.userInteraction
Human question/answer seam"] - pkg_stdio_demo["stdio-demo"] + pkg_tui["tui"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] + pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] @@ -133,7 +134,6 @@ flowchart LR pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore - pkg_stdio_demo --> svc_userInteraction pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -143,6 +143,7 @@ flowchart LR pkg_token_meter --> svc_tokenMeter pkg_tool_bash --> svc_bashEnv pkg_tools --> svc_tools + pkg_tui --> svc_userInteraction pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web pkg_web_fetch_local --> svc_web @@ -156,8 +157,8 @@ flowchart LR svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo svc_agents --> pkg_invariants - svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess + svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -208,8 +209,8 @@ flowchart LR svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web svc_userInteraction --> pkg_acp - svc_userInteraction --> pkg_stdio_demo svc_userInteraction --> pkg_tool_ask_user + svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web svc_workflows --> pkg_tool_workflow svc_fs -. event gate .-> pkg_fs_policy @@ -225,9 +226,9 @@ flowchart LR | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c2d31f93e3..e731070b6d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -860,88 +860,6 @@ export interface Config { Source: [`packages/spill/spill-policy/src/index.ts:45`](../packages/spill/spill-policy/src/index.ts) -## `@deepseek-ai/dsh-stdio` - -Requires: `agents` · `userInteraction` - -```ts config-catalog -/** Serializable plugin configuration (cordis-native, schemastery). */ -export interface Config { - /** Banner printed once on start, before the first `> ` prompt. */ - welcome?: string - /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ - sessionId?: string -} -``` - -Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts) - -## `@deepseek-ai/dsh-stdio-demo` - -```ts config-catalog -/** - * App config: the swappable per-demo values, each routed to where the app wires - * it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is - * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` - * is the explicit model-facing tool order (forwarded to the system-prompt plugin); - * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions - * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner and `ui` configures terminal mode/presentation. - */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent (must have a registered adapter). */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona (the system-prompt plugin's `persona` config). */ - persona?: string - /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ - toolOrder?: string[] - /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ - welcome?: string - /** Terminal front-door selection and pi-tui presentation settings. */ - ui?: UiConfig - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-core. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ - toolTasks?: NonNullable - /** - * If set, the pre-created agent RESUMES this persisted session id instead of - * starting fresh. Sourced from an env var in the leaf `cordis.yml` - * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). - */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -/** App-level terminal selection with nested TUI presentation settings. */ -export interface UiConfig { - /** Select a concrete front door or infer it from the process streams. */ - mode?: TerminalMode - /** Settings forwarded only when the pi-tui front door is selected. */ - tui?: uiTui.TuiConfig -} - -/** Terminal front door selected by the app bundle. */ -export type TerminalMode = 'auto' | 'readline' | 'tui' -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/stdio-demo/src/index.ts:75`](../packages/examples/stdio-demo/src/index.ts) - ## `@deepseek-ai/dsh-subagent-acp` Requires: `subagents` @@ -1320,6 +1238,48 @@ export interface TuiConfig { Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +## `@deepseek-ai/dsh-tui-demo` + +```ts config-catalog +/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the `main` agent. */ + provider: string + /** Model name for the `main` agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** TUI subtitle rendered on start. Defaults to `ready.`. */ + welcome?: string + /** Full-screen TUI presentation settings. */ + ui?: uiTui.TuiConfig + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** Persisted session id to resume instead of creating a fresh session. */ + resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} +``` + +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) + +Source: [`packages/examples/tui-demo/src/index.ts:28`](../packages/examples/tui-demo/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 5b7f226916..9818bb8671 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 37793e4e76bf5171c759ca78be473912101bd9f4 -extension-cookbook.zh.md: 8f170f225b55721c78ef27c0e87e481b5cb00f64 +extension-cookbook.md: 811eeb04d1730a8062454f932477d1e05275f3cf +extension-cookbook.zh.md: a1c22aeffcd337401bed9444ebd52ebd5b524595 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 37793e4e76..811eeb04d1 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Six runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/repl-agent`](../../examples/repl-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the same capability class behind a one-shot task and DSH-native output, `pnpm run demo:headless -- "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the headless leaf loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Five runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (keyless mock model + echo tool through Headless, `pnpm run demo:echo "task"`), [`examples/tui-agent`](../../examples/tui-agent) (DeepSeek coding tools through the full-screen TUI, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the coding capabilities behind a one-shot task and DSH-native output, `pnpm run demo:headless "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting through the TUI, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). Interactive leaves load [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves load [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8f170f225b..a1c22aeffc 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -六个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/repl-agent`](../../examples/repl-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(同类能力通过单次任务和 DSH 原生输出运行,`pnpm run demo:headless -- "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),headless 叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 +五个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(通过 Headless 运行的 keyless mock 模型 + echo 工具,`pnpm run demo:echo "task"`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 TUI 运行的 DeepSeek coding 工具,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(通过单次任务和 DSH 原生输出运行的 coding 能力,`pnpm run demo:headless "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(通过 TUI 进行自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。交互式叶子加载 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index dcca6355e9..46e17f3f83 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` selects keyboard-driven `dsh-tui` overlays or `dsh-stdio` readline prompts, and `dsh-acp` maps questions to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-tui` uses keyboard-driven overlays, and `dsh-acp` maps questions to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2ca3f14fbc..d05265a584 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 94eb4f03329b574862a1ac1de2f8c1d4db4f4a0a -development.zh.md: b533aff43a66ff7cfc5dc61e5b9b224a01c51f12 +development.md: 6517a14f094a5098c26815e04f81e3f8ea1ceff9 +development.zh.md: bb70970679aaa8ffaed927b746002277cb422f6b diff --git a/docs/development.md b/docs/development.md index 94eb4f0332..6517a14f09 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ This onboarding guide helps project contributors get started with the local envi - Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md). - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git. -- Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests. +- Optional: a DeepSeek API key for the TUI/Headless/ACP agent demos and real-API e2e tests. ## First-time setup @@ -102,19 +102,13 @@ When changing package public behavior, update the relevant README or JSDoc in th ## Demos -The echo demo does not need API credentials: +The Headless echo demo does not need API credentials: ```sh -pnpm run demo:echo +pnpm run demo:echo "echo hello" ``` -The repl-agent demo uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: - -```sh -pnpm run demo:repl -``` - -The full-screen TUI reuses the repl-agent composition through the pi-tui front door and needs the same credentials: +The full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:tui diff --git a/docs/development.zh.md b/docs/development.zh.md index b533aff43a..bb70970679 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -9,7 +9,7 @@ - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。 - Git。 -- 可选:一个 DeepSeek API key,用于 REPL/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。 +- 可选:一个 DeepSeek API key,用于 TUI/Headless/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。 ## 首次搭建 @@ -102,19 +102,13 @@ pnpm run hygiene # knip, publint, workspace constraints, and NodeNext dec ## 演示 -echo 演示不需要 API 凭证: +Headless echo 演示不需要 API 凭证: ```sh -pnpm run demo:echo +pnpm run demo:echo "echo hello" ``` -repl-agent 示例使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: - -```sh -pnpm run demo:repl -``` - -全屏 TUI 通过 pi-tui 前端复用 repl-agent 组装,并需要相同的凭证: +全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:tui diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b678c4b7e2..c1ec91e378 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,9 +7,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:159`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:314`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | @@ -18,8 +18,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:229`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:168`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:291`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:301`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index d477bd6d62..516d041166 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -13,7 +13,6 @@ The process decision behind this index is recorded in [the documentation graph A | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | -| [repl-agent app composition](../examples/repl-agent/composition.md) | `hybrid generated` | | [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | | [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index bfbb583303..e7943bedae 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Good: `A green gate does not mean the translation is correct.` ### Code block comments — never translate -- Source code block contains: `# readline coding agent (needs DEEPSEEK_API_KEY)` -- Bad: `# readline 编码 agent(需要 DEEPSEEK_API_KEY)` -- Good: `# readline coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) +- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` +- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)` +- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) ### Language switcher — English to Chinese - Source: `English | [中文](README.zh.md)` diff --git a/docs/module-graph.md b/docs/module-graph.md index 0ed79d32f8..84068531fe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,7 +108,6 @@ flowchart TD pkg_app_boot["app-boot"] pkg_jsonrpc["jsonrpc"] pkg_permission["permission"] - pkg_stdio["stdio"] pkg_tool_ask_user["tool-ask-user"] pkg_tui["tui"] pkg_user_approval["user-approval"] @@ -127,7 +126,7 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] - pkg_stdio_demo["stdio-demo"] + pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -395,11 +394,6 @@ flowchart TD pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent - pkg_stdio --> pkg_agent - pkg_stdio --> pkg_agent_loop - pkg_stdio --> pkg_llm - pkg_stdio --> pkg_session - pkg_stdio --> pkg_user_interaction pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_llm @@ -449,19 +443,18 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_stdio_demo --> pkg_agent - pkg_stdio_demo --> pkg_agent_loop - pkg_stdio_demo --> pkg_agent_spine_demo - pkg_stdio_demo --> pkg_app_boot - pkg_stdio_demo --> pkg_llm - pkg_stdio_demo --> pkg_session - pkg_stdio_demo --> pkg_session_persistence_jsonl - pkg_stdio_demo --> pkg_stdio - pkg_stdio_demo --> pkg_tool_ask_user - pkg_stdio_demo --> pkg_tools - pkg_stdio_demo --> pkg_tui - pkg_stdio_demo --> pkg_user_interaction - pkg_stdio_demo --> pkg_workspace_context + pkg_tui_demo --> pkg_agent + pkg_tui_demo --> pkg_agent_loop + pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_app_boot + pkg_tui_demo --> pkg_llm + pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_persistence_jsonl + pkg_tui_demo --> pkg_tool_ask_user + pkg_tui_demo --> pkg_tools + pkg_tui_demo --> pkg_tui + pkg_tui_demo --> pkg_user_interaction + pkg_tui_demo --> pkg_workspace_context ``` | Package | Group | Depends on | @@ -550,7 +543,6 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -558,4 +550,4 @@ flowchart TD | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index e024f4d698..10e88c390c 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -24,7 +24,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `stdio-chat`, …). But it *also* ended with one extra line no other plugin had: +`packages/ui/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index d2339d35a7..3caf1415fa 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,7 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -448,7 +448,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 30805c97b4..945b79ab3c 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-adapter.md: f34fc9e1d5b59a323bb562764821ef910025880e -llm-adapter.zh.md: 3c781ae8a1a011e2f73d5f6de43f6f75e1fb549f +llm-adapter.md: 83296e54220c668410fe69d689199171251a7787 +llm-adapter.zh.md: 89b7185690dbdfe33cbafe7b0ee4c3e83cfe0df8 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index f34fc9e1d5..83296e5422 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -131,10 +131,12 @@ The first argument lists the model names handled by the adapter. If `cordis.yml` - my-model-v1 - my-model-v2 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: my-llm model: my-model-v1 # References the model registered above. + workspaceContext: false ``` ## Reference implementations diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 3c781ae8a1..89b7185690 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -131,10 +131,12 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - my-model-v1 - my-model-v2 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: my-llm model: my-model-v1 # References the model registered above. + workspaceContext: false ``` ## 实战参考 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 9894ca95bc..bbe68e65e9 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -config.md: a3f56018fd43cc803c1710f97c29a77340a0b257 -config.zh.md: af661b9d7ef72e4085551202169e975bd0c3ec99 +config.md: 0616f163f995b152d7a28841506027558de2c32c +config.zh.md: fa91445ae88456a61a4736ce8b71aed482227ce8 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index a3f56018fd..0616f163f9 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -9,7 +9,8 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: - [echo-agent](../../../examples/echo-agent/cordis.yml) uses a local mock model and needs no API key. -- [repl-agent](../../../examples/repl-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, and workflows. +- [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) connects to editor clients over ACP. A minimal configuration is a list of plugin entries: @@ -22,10 +23,15 @@ A minimal configuration is a list of plugin entries: models: - deepseek-v4-flash -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## Plugin entries diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index af661b9d7e..fa91445ae8 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -9,7 +9,8 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: - [echo-agent](../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 -- [repl-agent](../../../examples/repl-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 最小配置由一组插件条目组成: @@ -22,10 +23,15 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 models: - deepseek-v4-flash -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## 插件条目 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 6743abcdd4..e2b307201e 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -index.md: a20b1041e13b01b6b1d01a5baa8975d3e68c6aa0 -index.zh.md: 56ec50352218e2e28ad2dd7a6ef387376de75606 +index.md: b698b8aeee6cebff374e20ca0f76ddc9e75213c0 +index.zh.md: 337d246baa12ccf6d7a9656d1ea3b06002554c13 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index a20b1041e1..b698b8aeee 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,10 +14,12 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the application template -- name: '@deepseek-ai/dsh-stdio-demo' +# Select the interactive application +- name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 56ec503522..337d246baa 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,10 +14,12 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the application template -- name: '@deepseek-ai/dsh-stdio-demo' +# Select the interactive application +- name: '@deepseek-ai/dsh-tui-demo' config: + provider: deepseek model: deepseek-v4-flash + workspaceContext: false ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index a4898be8e0..c3086cd4fd 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -quickstart.md: acae2ac095e057971043c2bcece7a52d3ebc1c2c -quickstart.zh.md: 54643fe54e62dbbd3696362cb43ff8569577c53b +quickstart.md: 62e899adfa33e566083b8224b9bdf77c1038557a +quickstart.zh.md: 382c9685ebe0c919c2fd039484898b44e9264aa7 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index acae2ac095..62e899adfa 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -7,91 +7,44 @@ This guide gets an agent running in five minutes. ## Prerequisites - [Node.js](https://nodejs.org/) ^22.19 or >= 24 -- [pnpm](https://pnpm.io/) 11 (use Corepack to select the repository-pinned version) +- [pnpm](https://pnpm.io/) 11 through Corepack ```sh -# Check versions -node -v # v22.19.x, or v24.x and newer +node -v corepack enable -pnpm -v # 11.x +pnpm -v ``` -## Step 1: run echo-agent - -echo-agent needs no API key and runs after dependencies are installed. +## Step 1: run the keyless Headless demo ```sh -# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness - -# Install dependencies pnpm install - -# Start echo-agent -pnpm run demo:echo +pnpm run demo:echo "echo hello world" ``` -The process prints: +The local mock model calls the `echo` tool, which returns the text in uppercase, and the final response is printed without opening an interactive UI. Use `--output-format stream-json` when you need the canonical event stream. -``` -echo-agent ready. Type a message ("echo " triggers the tool). -> -``` +## Step 2: use a real model in the TUI -Enter: - -``` -> echo hello world -``` - -The model issues a tool call, and the echo tool returns the text in uppercase: - -``` -[tool call] echo({"text":"hello world"}) -[tool result] ECHO: HELLO WORLD -``` - -Your local environment is ready. - -## Step 2: use a real model - -Next, connect a real DeepSeek model and run the complete command-line agent. - -### Get an API key - -Get an API key from [DeepSeek Platform](https://platform.deepseek.com/). - -### Configure the environment - -Create a gitignored `.env` file in the repository root: +Get an API key from [DeepSeek Platform](https://platform.deepseek.com/) and create the gitignored repository-root `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` -### Start repl-agent +Start the interactive coding agent: ```sh -pnpm run demo:repl +pnpm run demo:tui ``` -``` -agent REPL ready. Give it a coding task. -> -``` - -This is a complete coding assistant that can read and write files, run commands, and delegate subtasks. - -Try a task: - -``` -> Create hello.js in the current directory, print "Hello from Harness!", and run it -``` +The full-screen agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. ## What happened -echo-agent and repl-agent use the same application framework (`@deepseek-ai/dsh-stdio-demo`). Their `cordis.yml` files select different plugins and configuration. Custom agents use the same composition model. +echo-agent uses the Headless `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 54643fe54e..382c9685eb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -7,93 +7,46 @@ ## 环境准备 - [Node.js](https://nodejs.org/) ^22.19 或 >= 24 -- [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) +- 通过 Corepack 使用 [pnpm](https://pnpm.io/) 11 ```sh -# Check versions -node -v # v22.19.x, or v24.x and newer +node -v corepack enable -pnpm -v # 11.x +pnpm -v ``` -## 第一步:运行 echo-agent - -echo-agent 不需要 API key,装好依赖就能跑。 +## 第一步:运行 keyless Headless 演示 ```sh -# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness - -# Install dependencies pnpm install - -# Start echo-agent -pnpm run demo:echo +pnpm run demo:echo "echo hello world" ``` -启动后你会看到: +本地 mock 模型会调用 `echo` 工具,由工具返回大写文本,最终回复在不打开交互式 UI 的情况下直接输出。需要规范事件流时可使用 `--output-format stream-json`。 -``` -echo-agent ready. Type a message ("echo " triggers the tool). -> -``` +## 第二步:在 TUI 中使用真实模型 -试着输入: - -``` -> echo hello world -``` - -你会看到模型发起了一次 tool call(工具调用),echo 工具将文本转为大写并返回: - -``` -[tool call] echo({"text":"hello world"}) -[tool result] ECHO: HELLO WORLD -``` - -恭喜!环境没问题。 - -## 第二步:使用真实模型调用 - -接下来接入真实的 DeepSeek 模型,跑一个完整的命令行 Agent。 - -### 获取 API Key - -前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取你的 API key。 - -### 配置环境变量 - -在仓库根目录创建 `.env` 文件(已被 gitignore): +前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取 API key,并创建已被 Git 忽略的仓库根目录 `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` -### 启动 repl-agent +启动交互式 coding agent: ```sh -pnpm run demo:repl +pnpm run demo:tui ``` -``` -agent REPL ready. Give it a coding task. -> -``` - -这就是一个完整的编程助手,它能读写文件、跑命令、拆分子任务。 - -试着给它一个任务: - -``` -> Create hello.js in the current directory, print "Hello from Harness!", and run it -``` +这个全屏 Agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 ## 回头看 -echo-agent 和 repl-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-demo`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。 +echo-agent 使用 Headless `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择模型和能力插件。 ## 下一步 -- [配置文件](./config.md) — 了解 `cordis.yml` 的完整语法 -- [开发插件](../develop/basic/) — 编写你自己的 tool 或后端 +- [配置文件](./config.md) — 了解 `cordis.yml` 的格式 +- [开发插件](../develop/basic/) — 编写自己的 tool 或后端 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 6b820368e4..dde6e5e522 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -13,7 +13,7 @@ Each example has both: Mock-only examples require only the keyless tier; state that exception in the test. -Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. +Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/README.md b/examples/README.md index 6524a36f7f..f8489a4c1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,37 +1,29 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools or demo-only mocks. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. ## echo-agent -A mock model + echo tool on the stdio chat app — the all-mock skeleton. The leaf swaps `dsh-stdio-demo`'s LLM backend to a local `mock-echo` adapter and adds a local `echo` tool. Demonstrates: +A mock model + echo tool on the headless one-shot app — the all-mock skeleton. It demonstrates: -- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app +- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-cli-demo` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` -- "Swap the backend, keep the app" — the only difference from `repl-agent` is the adapter +- A network-free Headless task with text or DSH-native JSON output -Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. - -## repl-agent - -A coding agent with DeepSeek V4, the `read`/`write`/`edit` filesystem tools, the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. - -Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [repl-agent/README.md](repl-agent/README.md) for details. - -Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](repl-agent/README.md#code-mode) for its composition and a sample task. +Run with: `pnpm run demo:echo "echo hello"`. The task prefix `echo ` triggers a tool-call round trip. ## headless-agent A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. -Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. +Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. ## tui-agent -The full-screen terminal sibling of `repl-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. +The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios. -Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. +Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition. ## jsonrpc-agent diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 310fbb30af..a0bb2188da 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,6 +1,6 @@ # cordis-agent -The self-referential harness demo: the coding spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 499379482f..a812f71445 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,11 +20,11 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local - plugin_cordis_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_cordis_stdio_agent - plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] + plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] + cfg --> plugin_cordis_tui_agent + plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] @@ -41,7 +41,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | +| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 7c7c010c3a..39b6cd3b48 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,4 +1,4 @@ -# Self-referential stdio demo: the coding spine plus tools to inspect the live +# Self-referential TUI demo: the coding spine plus tools to inspect the live # service/plugin/tool/mount/API/event state, mount a model-written plugin under # `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. @@ -45,8 +45,8 @@ name: '@deepseek-ai/dsh-web-fetch-local' # The app bundle pre-creates the self-referential demo's `main` agent. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek model: deepseek-v4-flash diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index 24cf2138fb..6e5cca3b08 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -1,27 +1,23 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' -/** - * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree, - * including tool-cordis resolved by package name, then close stdin without a - * prompt and assert the banner. The dummy key never reaches a model call. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ +describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => { + it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => { + const output = await runTuiPtySmoke({ label: 'cordis-agent', - tempDirPrefix: 'cordis-smoke-', + tempDirPrefix: 'cordis-agent-smoke-', binScript, configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }], }) - expect(stdout).toContain('cordis-agent ready.') + expect(output).toContain('cordis-agent ready.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index f397cc633f..4503705439 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -1,34 +1,25 @@ # echo-agent -Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-mock skeleton — "swap the backend, keep the app". +Network-free Headless demo with a scripted mock model and an echo tool. ## What it shows -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: +The leaf loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), which supplies the shared spine, JSONL persistence, one fresh `main` agent, and the one-shot CLI driver. Two local plugins provide the demo behavior: -- `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. -- `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. +- `mock-llm.ts` registers a scripted `LlmAdapter`; a task beginning with `echo ` requests the tool. +- `echo-tool.ts` registers a typed tool that returns the input uppercased. -Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `repl-agent` — the same app, a different backend. - -## Plugin files - -| File | Role | Key patterns demonstrated | -|---|---|---| -| `src/mock-llm.ts` | `LlmAdapter` registration | `ctx.llm.registerAdapter(['mock-echo'], …)`, streaming chunks with the proper `block-start`/`block-end` protocol | -| `src/echo-tool.ts` | Tool registration | `ctx.tools.register(defineTool(…))` with typed `execute` args, returning `ContentBlock[]` | -| `cordis.yml` | Leaf wiring | the two backends + `hmr` + one `@deepseek-ai/dsh-stdio-demo` entry carrying the app config | - -The spine, UI, persistence, and boot glue all live in `@deepseek-ai/dsh-stdio-demo` and the bundle it loads — this folder holds only the demo-specific mocks and the leaf wiring. +| File | Role | +|---|---| +| `src/mock-llm.ts` | Streaming mock adapter | +| `src/echo-tool.ts` | Model-facing echo tool | +| `cordis.yml` | Mock plugins, local providers, and one `@deepseek-ai/dsh-cli-demo` entry | ## Run ```sh -pnpm run demo:echo -# or: -node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml +pnpm run demo:echo "echo hello world" +pnpm run demo:echo --output-format stream-json -- "echo hello world" ``` -Type a message and press Enter. "echo " triggers a tool call round-trip (the mock model requests the `echo` tool, which echoes the text uppercased, and the next model step acknowledges it). - -The session is persisted under `.sessions/` relative to the directory you launch the demo from. `pnpm run demo:echo` runs from the repo root, so the logs land in `/.sessions/cwd-/` (one `.jsonl` log per session). Clean up with: `rm -rf .sessions` +The first command prints the final canned response. `stream-json` also exposes the canonical `tool/call` and `tool/result` events. Sessions persist under `.sessions/` relative to the launch directory; remove that generated directory when finished. diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 15f8e078fb..1aadfda08c 100644 --- a/examples/echo-agent/composition.md +++ b/examples/echo-agent/composition.md @@ -3,13 +3,11 @@ # Echo Agent App Composition -The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door. +The echo demo swaps in a local mock LLM and teaching echo tool, then loads the headless one-shot app package. ```mermaid flowchart LR cfg["examples/echo-agent
cordis.yml"] - plugin_echo_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_echo_hmr plugin_echo_mock_llm["mock-llm
./src/mock-llm.ts"] cfg --> plugin_echo_mock_llm plugin_echo_echo_tool["echo-tool
./src/echo-tool.ts"] @@ -18,11 +16,11 @@ flowchart LR cfg --> plugin_echo_bash plugin_echo_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] cfg --> plugin_echo_fs_local - plugin_echo_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_echo_stdio_agent - plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] + plugin_echo_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] + cfg --> plugin_echo_cli_agent + plugin_echo_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_echo_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_echo_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
fresh top-level agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] @@ -31,12 +29,11 @@ flowchart LR | Plugin id | Package / module | | --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | | `mock-llm` | `./src/mock-llm.ts` | | `echo-tool` | `./src/echo-tool.ts` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | +| `cli-agent` | `@deepseek-ai/dsh-cli-demo` | Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 6b3d19839b..967c01f6a5 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -1,43 +1,26 @@ -# Stdio agent with the network-free `mock-echo` adapter and example-local `echo` -# tool. The app bundle supplies the spine; this leaf selects backends, HMR, and app config. -# No API key: the `mock-echo` adapter never touches the network. +# Headless agent with the network-free `mock-echo` adapter and example-local +# `echo` tool. No API key is needed because the adapter never touches the network. -# Hot-module reload for the dev/demo loop (a leaf entry, not baked into -# dsh-stdio-demo — it needs `node --expose-internals`, which `demo:echo` passes). -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# Example-local model and tool plugins resolve relative to this file. - id: mock-llm name: './src/mock-llm.ts' - id: echo-tool name: './src/echo-tool.ts' -# Local bash executor: agent-spine-demo ships the `tool-bash` consumer schema, so the -# leaf provides the executor it runs on (the echo demo doesn't drive bash, but -# the tool is part of the shared spine). - id: bash name: '@deepseek-ai/dsh-bash-local' -# Local filesystem provider for agent-spine-demo's workspace-context loader. This -# does not expose model-facing read/write/edit tools in the echo demo. - id: fs-local name: '@deepseek-ai/dsh-fs-local' config: cwd: !!js process.cwd() -# The app pre-creates `main` on the mock model and supplies persistence plus -# TTY-selected `dsh-tui`/`dsh-stdio` front doors; readline mode also owns logging. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' config: provider: mock model: mock-echo persona: 'You are echo-agent, a demo agent.' - welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 diff --git a/examples/echo-agent/package.json b/examples/echo-agent/package.json index 00982fa297..c3c4fd5553 100644 --- a/examples/echo-agent/package.json +++ b/examples/echo-agent/package.json @@ -3,5 +3,5 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Runnable demo: stdin chat with a scripted mock model + echo tool" + "description": "Runnable headless demo: scripted mock model + echo tool" } diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index db0d998336..cf15e22c5a 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -1,43 +1,37 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import type { SessionEvent } from '@deepseek-ai/dsh-session' -/** - * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real - * tree uses its deterministic mock model, so this suite is both the boot smoke - * and the complete behavior proof for the example. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -async function runEcho(stdinLines: readonly string[]): Promise { +async function runEcho(task: string, outputFormat: 'text' | 'stream-json' = 'text'): Promise { const { stdout } = await runLoaderSmoke({ label: 'echo-agent', tempDirPrefix: 'echo-smoke-', binScript, configPath, + binArgs: ['--config', configPath, '--output-format', outputFormat, task], tsconfigPath, - stdinLines, }) return stdout } -describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => { - expect(await runEcho([])).toContain('echo-agent ready.') +describe('echo-agent keyless smoke (Headless through the real Loader tree)', () => { + it('runs the echo tool round-trip and exposes both events in stream-json', async () => { + const lines = (await runEcho('echo hello world', 'stream-json')) + .trimEnd().split('\n').map(line => JSON.parse(line) as Record) + const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) + expect(events.some(event => event.type === 'tool/call' && event.data.name === 'echo')).toBe(true) + expect(JSON.stringify(events.find(event => event.type === 'tool/result'))).toContain('ECHO: HELLO WORLD') + expect(lines.at(-1)).toMatchObject({ type: 'result', success: true }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('runs the echo tool round-trip for an "echo …" line', async () => { - const stdout = await runEcho(['echo hello world']) - expect(stdout).toContain('[tool call] echo') - expect(stdout).toContain('[tool result] ECHO: HELLO WORLD') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('streams a direct canned reply for a non-echo line', async () => { - const stdout = await runEcho(['just chatting']) - expect(stdout).toContain('just chatting') - expect(stdout).not.toContain('[tool call]') + it('prints the final canned reply for a direct one-shot task', async () => { + const stdout = await runEcho('just chatting') + expect(stdout).toContain('You said: "just chatting"') + expect(stdout).not.toContain('tool/call') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml index 9b59e2ded9..8d55a8ac55 100644 --- a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml +++ b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml @@ -8,12 +8,11 @@ - id: time-context name: '@deepseek-ai/dsh-time-context' -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' config: provider: mock model: mock-echo persona: 'Test the time-context plugin.' - welcome: 'time-context e2e ready.' persistenceRoot: './.sessions' workspaceContext: false diff --git a/examples/echo-agent/tests/fixtures/context/time-context/driver.ts b/examples/echo-agent/tests/fixtures/context/time-context/driver.ts new file mode 100644 index 0000000000..ea73745faf --- /dev/null +++ b/examples/echo-agent/tests/fixtures/context/time-context/driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('time-context driver requires a config path') + +const ctx = await boot('time-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 285263146f..79cc348803 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -8,7 +8,7 @@ Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:headless -- "fix the failing test in this workspace" +pnpm run demo:headless "fix the failing test in this workspace" pnpm run demo:headless --output-format json -- "summarize the implementation" pnpm run demo:headless --output-format stream-json -- "run the focused tests" ``` diff --git a/examples/repl-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts similarity index 100% rename from examples/repl-agent/tests/code-mode.e2e.ts rename to examples/headless-agent/tests/code-mode.e2e.ts diff --git a/examples/repl-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts similarity index 100% rename from examples/repl-agent/tests/coding-task.e2e.ts rename to examples/headless-agent/tests/coding-task.e2e.ts diff --git a/examples/repl-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts similarity index 100% rename from examples/repl-agent/tests/compaction.e2e.ts rename to examples/headless-agent/tests/compaction.e2e.ts diff --git a/examples/repl-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts similarity index 100% rename from examples/repl-agent/tests/full-loop.e2e.ts rename to examples/headless-agent/tests/full-loop.e2e.ts diff --git a/examples/repl-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts similarity index 98% rename from examples/repl-agent/tests/harness.ts rename to examples/headless-agent/tests/harness.ts index edf611e89d..ca1c59871e 100644 --- a/examples/repl-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -15,7 +15,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** - * Shared harness for the repl-agent e2e suites: the full plugin stack + * Shared harness for the headless-agent e2e suites: the full plugin stack * with the real DeepSeek adapter and the real bash + todo_write tools. Lives * outside the *.e2e.ts pattern so importing it never re-registers another * file's tests. diff --git a/examples/repl-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts similarity index 100% rename from examples/repl-agent/tests/resume.e2e.ts rename to examples/headless-agent/tests/resume.e2e.ts diff --git a/examples/repl-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts similarity index 100% rename from examples/repl-agent/tests/todo-write.e2e.ts rename to examples/headless-agent/tests/todo-write.e2e.ts diff --git a/examples/package.json b/examples/package.json index 687c624536..53c392cd4c 100644 --- a/examples/package.json +++ b/examples/package.json @@ -9,6 +9,7 @@ "@cordisjs/plugin-include": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -31,7 +32,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-stdio-demo": "workspace:*", + "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", diff --git a/examples/repl-agent/README.md b/examples/repl-agent/README.md deleted file mode 100644 index f89e24618c..0000000000 --- a/examples/repl-agent/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# repl-agent - -The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:repl -``` - -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. - -The REPL renders reasoning, tool calls/results, and the latest todo list as line-oriented output suitable for terminals and pipes. Use `pnpm run demo:tui` for the interactive Markdown/card interface. - -### Resuming a prior session - -Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, set `RESUME_SESSION_ID` to that session's id — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: - -```sh -RESUME_SESSION_ID= pnpm run demo:repl -``` - -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero, while readline reports any dropped queued input and allows piped EOF to finish. Unset it or choose an existing session id. - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. - -```sh -pnpm run demo:code-mode # this overlay under the REPL (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -## What each leaf entry demonstrates - -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the selected terminal channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: - -| Entry | Demonstrates | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | -| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | -| `token-meter`, `tool-result-prune`, `compact-basic` | replay-aware pressure, model-free oversized tool-result pruning, and LLM summary compaction. Pruning runs only after a compaction trigger qualifies and can avoid the summarization call | -| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | -| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | -| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | -| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | - -## End-to-end tests (`pnpm run test:e2e`, key-gated) - -- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. -- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. -- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so automatic pruning or summary compaction fires mid-session. It verifies the world: a replayable surface replacement lands, summary brackets are complete when summarization is needed, the surface shrinks, and the agent still produces a correct final answer. -- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event. - -These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`. diff --git a/examples/repl-agent/code-mode.cordis.yml b/examples/repl-agent/code-mode.cordis.yml deleted file mode 100644 index 8802b510ba..0000000000 --- a/examples/repl-agent/code-mode.cordis.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, -# `run_code`, plus a generated SDK for bash/read/write/edit/subagent/todo_write. -# `demo:code-mode` selects this overlay; the ACP example has the same UI-specific -# shape. A config patch replaces the whole app config, so unchanged base fields -# are restated; only `tools`, `welcome`, and the persona's second paragraph differ. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - tools: - mode: code - welcome: 'code-mode agent ready. Give it a multi-tool task.' - ui: - mode: readline - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md deleted file mode 100644 index 6d298e7a0a..0000000000 --- a/examples/repl-agent/composition.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# REPL Agent App Composition - -The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package. - -```mermaid -flowchart LR - cfg["examples/repl-agent
cordis.yml"] - plugin_repl_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_repl_hmr - plugin_repl_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_repl_llm_deepseek - plugin_repl_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_repl_bash - plugin_repl_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_repl_stdio_agent - plugin_repl_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_repl_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_repl_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_repl_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_repl_token_meter - plugin_repl_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_repl_tool_result_prune - plugin_repl_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] - cfg --> plugin_repl_compact_basic - plugin_repl_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_repl_subagent - plugin_repl_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] - cfg --> plugin_repl_subagent_spawn - plugin_repl_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] - cfg --> plugin_repl_subagent_fork - plugin_repl_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_repl_tool_subagent - plugin_repl_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_repl_tool_subagent_fork - plugin_repl_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] - cfg --> plugin_repl_workflow_workerthread - plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_repl_tool_workflow - plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_repl_tool_todo - plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_repl_fs_local - plugin_repl_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_repl_fs_policy - plugin_repl_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_repl_tool_fs - plugin_repl_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_repl_tool_fs_search - plugin_repl_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_repl_timeout_policy - plugin_repl_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_repl_spill_local - plugin_repl_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_repl_spill_policy -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | -| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | -| `subagent` | `@deepseek-ai/dsh-subagent` | -| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | -| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | -| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | -| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | -| `spill-local` | `@deepseek-ai/dsh-spill-local` | -| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | - -Source config: [`examples/repl-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml deleted file mode 100644 index 6e78f7af98..0000000000 --- a/examples/repl-agent/cordis.yml +++ /dev/null @@ -1,143 +0,0 @@ -# Readline coding REPL with swappable DeepSeek and local-bash backends. -# `dsh-stdio-demo` supplies the agent spine, workspace instructions, generic -# task controls, JSONL persistence, the line-oriented front door, and `main`. -# HMR remains a leaf because it requires Loader internals; `demo:repl` passes -# `--expose-internals`. The app bin loads the gitignored root `.env`; this file -# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. - -# Hot-module reload for the dev/demo loop (needs `node --expose-internals`). -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The native DeepSeek adapter. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - -# Local executor for the app bundle's bash tool. -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# The app bundle pre-creates the REPL's `main` agent. -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live - # under ./.sessions); unset starts a fresh session each run. - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'agent REPL ready. Give it a coding task.' - ui: - mode: readline - # Keep the persona to identity and behavior; tool plugins own tool guidance. - # The loop resolves {{model}} from this agent's configuration. - persona: | - You are a coding agent powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -# Replay-aware request pressure with one service-wide context window. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -# Prune oversized tool output without a model call before summary compaction. -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - -# Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. Each tool instance needs a distinct `toolName`; the registry -# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' -# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Policy loads before the model-facing filesystem tools so writes and edits require -# an observed file. This single-session app resolves relative paths from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Bash-backed discovery tools (glob/grep): if the local bash executor above -# can find rg, register fixed ripgrep commands — not ctx.fs. Capped results -# save the complete formatted list through the spill backend below -# (ctx.spillStore, optional). -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs -# (the search tools above declare 30s) as a deadline on exec.signal. Without -# it a declared budget is advisory and only the bash executor's own timeout -# backstop applies. -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -# Tool-output spill stack: a local backend that saves oversized tool text under -# a private session-scoped dir, and the tools/post-execute policy that replaces -# an over-budget plain-text result with a preview + the spill locator/retrieval -# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until -# a tool returns more than maxInlineBytes of plain text. -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 diff --git a/examples/repl-agent/package.json b/examples/repl-agent/package.json deleted file mode 100644 index 34c7db6918..0000000000 --- a/examples/repl-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "repl-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: an agent REPL UI with DeepSeek V4 and coding tools" -} diff --git a/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts deleted file mode 100644 index dd4239a2c4..0000000000 --- a/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -/** - * Keyless Loader-path smoke for the Code Mode overlay: boot the real include - * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without - * a prompt and assert the banner. No model or `run_code` turn runs. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => { - it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ - label: 'code-mode overlay', - tempDirPrefix: 'code-mode-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - }) - expect(stdout).toContain('code-mode agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/repl-agent/tests/keyless-smoke.e2e.ts b/examples/repl-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index 62eb43f55a..0000000000 --- a/examples/repl-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -/** - * Keyless Loader-path smoke for examples/repl-agent: boot the real example - * through the stdio-agent bin and its `cordis.yml`, then close stdin without a - * prompt and assert the banner. The dummy key satisfies adapter construction; - * immediate EOF guarantees there is no model call. - */ - -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('repl-agent keyless smoke (real cordis.yml via the Loader)', () => { - it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout } = await runLoaderSmoke({ - label: 'repl-agent', - tempDirPrefix: 'repl-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - }) - expect(stdout).toContain('agent REPL ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fb8b10e7f4..4af7be198f 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,6 +1,6 @@ # tui-agent -The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. +The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows, `todo_write`, timeout/spill policy, and [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo). ## Run it @@ -8,16 +8,16 @@ The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README. pnpm run demo:tui ``` -The command needs `DEEPSEEK_API_KEY` in the environment or the gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. +The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. -Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. +Run `pnpm run demo:code-mode tui` for the Code Mode overlay. ## Composition -[`cordis.yml`](cordis.yml) includes the readline repl-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the repl-agent Code Mode overlay. +[`cordis.yml`](cordis.yml) owns the interactive coding composition directly. [`code-mode.cordis.yml`](code-mode.cordis.yml) includes that leaf and replaces the tool presentation mode while adding the code runtime. Non-interactive automation uses the sibling [headless-agent](../headless-agent/README.md) composition. ## Snapshot tests -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. +`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tools, then compares readable terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 75d2cea38a..45f5a7af04 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -1,12 +1,12 @@ -# Code Mode keeps the TUI front door while reusing the repl-agent overlay's -# worker runtime and one-tool registry composition. +# Code Mode keeps the TUI composition while adding the worker runtime and +# reducing the model-facing registry to the `run_code` transport. - id: base name: '@cordisjs/plugin-include' config: - path: ../repl-agent/code-mode.cordis.yml + path: ./cordis.yml patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' + - id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: deepseek model: deepseek-v4-flash @@ -18,13 +18,14 @@ mode: code welcome: 'TUI Code Mode ready. Give it a multi-tool task.' ui: - mode: tui - tui: - showReasoning: true - maxToolOutputLines: 12 + showReasoning: true + maxToolOutputLines: 12 persona: | You are a coding agent powered by the {{model}} model. You work by writing TypeScript programs for run_code: batch related tool work into one program, loop and branch where it helps, and print or return ONLY the findings that matter. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 94515c32c4..bed564ae34 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -3,25 +3,88 @@ # TUI Agent App Composition -The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. +The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. ```mermaid flowchart LR cfg["examples/tui-agent
cordis.yml"] - plugin_tui_base["base
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_tui_base - plugin_tui_base --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_tui_base --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_tui_base --> frontdoor_stdio["@deepseek-ai/dsh-tui
pre-created main agent"] + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr + plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_tui_llm_deepseek + plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_tui_bash + plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] + cfg --> plugin_tui_tui_agent + plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_tui_token_meter + plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_tui_tool_result_prune + plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_tui_compact_basic + plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_tui_subagent + plugin_tui_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_tui_subagent_spawn + plugin_tui_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_tui_subagent_fork + plugin_tui_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_tui_tool_subagent + plugin_tui_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_tui_tool_subagent_fork + plugin_tui_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_tui_workflow_workerthread + plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_tui_tool_workflow + plugin_tui_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_tui_tool_todo + plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_tui_fs_local + plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_tui_fs_policy + plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_tui_tool_fs + plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_tui_tool_fs_search + plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_tui_timeout_policy + plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_tui_spill_local + plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_tui_spill_policy ``` | Plugin id | Package / module | | --- | --- | -| `base` | `@deepseek-ai/dsh-stdio-demo` | +| `hmr` | `@cordisjs/plugin-hmr` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 515274b2a8..73b42b284a 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,28 +1,109 @@ -# Full-screen TUI front door over the same repl-agent composition used by the -# readline REPL. The include keeps backends and optional tools aligned; the -# patch owns only the terminal-specific app config. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ../repl-agent/cordis.yml - patches: - - id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'TUI agent ready. Give it a coding task.' - ui: - mode: tui - tui: - showReasoning: true - maxToolOutputLines: 12 - persona: | - You are a coding agent powered by the {{model}} model. +# Full-screen coding agent with swappable DeepSeek and local capability backends. +# `dsh-tui-demo` supplies the spine, workspace instructions, generic task controls, +# JSONL persistence, the TUI front door, and `main`. HMR remains a leaf because +# it requires Loader internals; `demo:tui` passes `--expose-internals`. - Verify your work by running the code or tests. Keep answers brief and - factual. +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'TUI agent ready. Give it a coding task.' + ui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index e40405524e..f8de00a1a6 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -12,8 +12,8 @@ config: cwd: !!js process.cwd() -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' config: provider: tui-scripted model: tui-scripted-model @@ -22,6 +22,4 @@ maxBytes: 65536 welcome: 'scripted TUI ready.' ui: - mode: tui - tui: - showReasoning: true + showReasoning: true diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts new file mode 100644 index 0000000000..21d0b4c9d7 --- /dev/null +++ b/examples/tui-agent/tests/pty-harness.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const PTY_DRIVER = String.raw` +import errno, json, os, pty, select, signal, sys, time +node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] +env = os.environ.copy() +env.update(json.loads(launch_env_json)) +env.update({"COLUMNS": "100", "LINES": "30"}) +actions = json.loads(actions_json) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, *json.loads(launch_args_json)], env) + +output = bytearray() +action_index = 0 +deadline = time.monotonic() + float(timeout_seconds) +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: + os.write(fd, actions[action_index]["send"].encode()) + action_index += 1 + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if action_index != len(actions): + sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions before timeout\n") + sys.exit(124) +actual_exit = os.waitstatus_to_exitcode(status) +if actual_exit != int(expected_exit): + sys.stderr.write(f"expected exit {expected_exit}, got {actual_exit}\n") + sys.exit(125) +` + +/** One terminal action sent after its marker has rendered. */ +interface TuiPtyAction { + readonly waitFor: string + readonly send: string +} + +/** Inputs for a keyless real-Loader TUI process smoke. */ +export interface TuiPtySmokeOptions { + readonly label: string + readonly tempDirPrefix: string + readonly binScript: string + readonly configPath: string + readonly tsconfigPath: string + readonly actions?: readonly TuiPtyAction[] + readonly env?: Readonly + readonly expectedExitCode?: number + readonly timeoutMs?: number +} + +/** + * Boot an example in a real pseudo-terminal, drive marker-gated input, and + * return the captured terminal bytes after the expected process exit. + * @param options - launch paths, environment, actions, and expected exit code. + * @returns complete pseudo-terminal output. + */ +export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { + const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) + const timeoutMs = options.timeoutMs ?? 25_000 + try { + const launch = resolveExampleLaunch({ + srcBin: options.binScript, + configArgs: [options.configPath], + tsconfigPath: options.tsconfigPath, + exposeInternals: true, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...options.env, + }, + }) + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs + 5_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve(stdout) + else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7cc8aebe32..e2fa7377c6 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,157 +1,42 @@ -import { spawn } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { runTuiPtySmoke } from './pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(launch_env_json)) -env.update({ - "COLUMNS": "100", - "LINES": "30", -}) -if resume_session_id: - env["RESUME_SESSION_ID"] = resume_session_id -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe(node, [node, *json.loads(launch_args_json)], env) - -output = bytearray() -answered_question = False -sent_prompt = False -sent_exit = False -deadline = time.monotonic() + 25 -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - if chunk: - output.extend(chunk) - if scenario == "conversation" and not sent_prompt and b"scripted TUI ready." in output: - os.write(fd, b"exercise the TUI\r") - sent_prompt = True - if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: - os.write(fd, b"\r") - answered_question = True - if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: - os.write(fd, b"/exit\r") - sent_exit = True - if scenario == "boot" and not sent_exit and b"TUI agent ready." in output: - os.write(fd, b"/exit\r") - sent_exit = True - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if scenario == "resume-failure": - if b'ui-tui: session "missing-session" failed to start:' not in output: - sys.stderr.write("TUI did not render the startup failure before timeout\n") - sys.exit(126) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: - sys.stderr.write("TUI startup failure did not exit with status 1\n") - sys.exit(127) -elif scenario == "conversation": - if not sent_prompt: - sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") - sys.exit(128) - if not answered_question: - sys.stderr.write("TUI did not render the user-question dialog before timeout\n") - sys.exit(129) - if not sent_exit: - sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") - sys.exit(130) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI scripted conversation did not exit cleanly\n") - sys.exit(131) -else: - if not sent_exit: - sys.stderr.write("TUI did not render its welcome marker before timeout\n") - sys.exit(124) - if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI child did not exit cleanly\n") - sys.exit(125) -` - -interface TuiLoaderSmokeOptions { - config?: string - resumeSessionId?: string - scenario?: 'boot' | 'conversation' | 'resume-failure' -} - -async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { - const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) - try { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [options.config ?? configPath], - tsconfigPath, - exposeInternals: true, - env: { - DEEPSEEK_API_KEY: 'keyless-tui-no-call', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - options.resumeSessionId ?? '', - options.scenario ?? 'boot', - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - child.once('error', reject) - child.once('exit', (code) => { - if (code === 0) resolve(stdout) - else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - }) - } finally { - await rm(cwd, { recursive: true, force: true }) - } -} - describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { - const output = await runTuiLoaderSmoke() + const output = await runTuiPtySmoke({ + label: 'tui-agent boot', + tempDirPrefix: 'tui-agent-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + actions: [{ waitFor: 'TUI agent ready.', send: '/exit\r' }], + }) expect(output).toContain('DEEPSEEK') expect(output).toContain('TUI agent ready.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { - const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) + const output = await runTuiPtySmoke({ + label: 'tui-agent conversation', + tempDirPrefix: 'tui-agent-conversation-', + binScript, + configPath: scriptedConfigPath, + tsconfigPath, + actions: [ + { waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' }, + { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, + ], + }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) @@ -159,14 +44,23 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') - expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') - expect(output).toContain('Decision received. Scripted TUI run complete.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) + const output = await runTuiPtySmoke({ + label: 'tui-agent resume failure', + tempDirPrefix: 'tui-agent-resume-', + binScript, + configPath, + tsconfigPath, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + RESUME_SESSION_ID: 'missing-session', + }, + expectedExitCode: 1, + }) expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/knip.json b/knip.json index d8e543e75c..129f181e9f 100644 --- a/knip.json +++ b/knip.json @@ -121,18 +121,14 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/examples/stdio-demo": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "packages/examples/tui-demo": { + "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/examples/cli-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/stdio": { - "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] - }, "packages/ui/tui": { "entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/package.json b/package.json index 995a2bf998..7ed2c83a04 100644 --- a/package.json +++ b/package.json @@ -78,12 +78,11 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", - "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", + "demo:echo": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/echo-agent/cordis.yml", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/tui-agent/cordis.yml", + "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/README.md b/packages/README.md index 9c6a979d61..d6f1b3355b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,7 +31,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | -| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | +| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index f14981ea36..d2532dc8d7 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -1,34 +1,21 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' // Keep the Loader config under examples so both modes exercise the same deployable // topology: local fixture source plus bare plugins owned by the examples workspace. -const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) +const driver = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/context/time-context/driver.ts', + import.meta.url, +)) const configPath = fileURLToPath(new URL( '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml', import.meta.url, )) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const PROCESS_TIMEOUT_MS = 30_000 -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:' -const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:' - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) async function jsonlFiles(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) @@ -40,68 +27,25 @@ async function jsonlFiles(dir: string): Promise { return paths.flat() } -async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { - workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: [configPath], +describe('time-context through a real headless cordis.yml', () => { + it('uses the process zone and persists one ordered context event per request', async () => { + let events: SessionEvent[] = [] + const { stderr } = await runLoaderSmoke({ + label: 'time-context headless smoke', + tempDirPrefix: 'time-context-e2e-', + binScript: driver, + libBinScript: driver, + configPath, tsconfigPath: repoTsconfig, - exposeInternals: true, - env: { - TZ: 'Asia/Shanghai', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), + env: { TZ: 'Asia/Shanghai' }, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) }, }) - const proc = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stdout = '' - let stderr = '' - let sentSecond = false - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { - stdout += chunk - if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo " to see a tool call.\n> ')) { - sentSecond = true - proc.stdin.end('second\n') - } - }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, stderr }) - else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - proc.on('error', (error) => { clearTimeout(timer); reject(error) }) - proc.stdin.write('first\n') - }) -} - -describe('time-context through a real cordis.yml and stdio process', () => { - it('uses the process zone and persists one ordered context event per request', async () => { - const { stdout, stderr } = await runTwoTurns() expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('time-context e2e ready.') - expect(stdout).toContain(FIRST_REPLY) - expect(stdout).toContain(SECOND_REPLY) - - const logs = await jsonlFiles(join(workdir as string, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) const contexts = events.filter(event => event.type === 'context/message') @@ -127,5 +71,5 @@ describe('time-context through a real cordis.yml and stdio process', () => { const headers = events.filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 99a68b062f..995881902e 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for the stdio demo, the terminal. + * must land somewhere the user can see — for a terminal front door, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index cadc176aea..c7e2d3ac5b 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -47,9 +47,9 @@ describe('config-driven session id', () => { it('accepts one exact fresh id and rejects it alongside a resume id', async () => { const exact = await makeCoreContext() await exact.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }], }) - expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact') await exact.fiber.dispose() const conflicting = await makeCoreContext() @@ -89,13 +89,13 @@ describe('config-driven session id', () => { const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) - const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] } + const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) let first: Agent | undefined for (let i = 0; i < 50 && first === undefined; i++) { await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('stdio-exact-reload')) + first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) @@ -106,14 +106,14 @@ describe('config-driven session id', () => { let second: Agent | undefined for (let i = 0; i < 50 && second === undefined; i++) { await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('stdio-exact-reload')) + second = ctx.agents.get(SessionId('config-exact-reload')) } expect(second).toBeDefined() expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) await waitForIdle(ctx, second!) await ctx.sessions.flush(second!.session) - const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload')) + const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) await secondLoop.dispose() @@ -125,7 +125,7 @@ describe('config-driven session id', () => { dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const sessionId = SessionId('stdio-exact-overlap') + const sessionId = SessionId('config-exact-overlap') const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() @@ -169,7 +169,7 @@ describe('config-driven session id', () => { dirs.push(root) const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) - const sessionId = SessionId('stdio-exact-cancel') + const sessionId = SessionId('config-exact-cancel') const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() @@ -213,20 +213,20 @@ describe('config-driven session id', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }], }) await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( - 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', + 'config-driven restore of "config-exact-failure" failed: Error: persistence index failed', )) - expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) + expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }]) expect(warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener threw: Error: failure observer failed', ) await expect.poll(() => warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener rejected: Error: async failure observer failed', ) - expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined() warn.mockRestore() await ctx.fiber.dispose() }) @@ -251,12 +251,12 @@ describe('config-driven session id', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }], }) await expect.poll(() => failures).toEqual([unrenderable]) expect(warn).toHaveBeenCalledWith( - 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + 'agent "main": config-driven restore of "config-exact-unrenderable" failed: ', ) expect(warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener threw: ', @@ -281,7 +281,7 @@ describe('config-driven session id', () => { ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], }) let disposed = false const disposal = loop.dispose().then(() => { disposed = true }) @@ -291,7 +291,7 @@ describe('config-driven session id', () => { if (outcome === 'resolve') listing.resolve([]) else listing.reject(new Error('startup cancelled by teardown')) await disposal - expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined() expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() warn.mockRestore() diff --git a/packages/examples/README.md b/packages/examples/README.md index 5703039d94..f13d15a8ea 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index e70881af8c..e04c3350c9 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -2,7 +2,7 @@ The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio. -It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster. +It is the structured counterpart to [`@deepseek-ai/dsh-tui-demo`](../tui-demo/README.md): both consume the same spine, but ACP creates sessions from its client and reserves stdout for its wire protocol. ## What it bakes in — and what it deliberately omits @@ -19,7 +19,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | -Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.) +Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead. ## Config diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 10ee749f8c..fc66da14c2 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -12,8 +12,8 @@ import * as acpAgent from '../src/index.ts' /** * In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition: * mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP - * bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO - * Loader-only plugin (no hmr), so it mounts in a plain Context. + * bridge in one `ctx.plugin`. It loads no Loader-only plugin (no hmr), so it + * mounts in a plain Context. * * The REAL Loader-path guard (export shape via `unwrapExports`, the headline * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`; diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 9c431d5698..ce5a01ff53 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -34,7 +34,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../stdio-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. +- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. @@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 760a0f60e1..fa675b69e2 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-cli-demo -Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. +Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. -The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. +The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. ## Config @@ -32,7 +32,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] The root headless-agent example supplies its leaf: ```sh -pnpm run demo:headless -- "inspect the failing test and fix it" +pnpm run demo:headless "inspect the failing test and fix it" ``` Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md deleted file mode 100644 index 2d706e3008..0000000000 --- a/packages/examples/stdio-demo/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# @deepseek-ai/dsh-stdio-demo - -The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`. - -It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client. - -## What it bakes in - -A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it: - -| Plugin | Why it is here | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | -| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | -| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | -| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | -| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity | -| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity | - -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. - -The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends. - -## Config - -| Key | Default | Routed to | -|---|---|---| -| `provider` | (required) | the pre-created `main` agent's registered provider route | -| `model` | (required) | the pre-created `main` agent's model | -| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | -| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | -| `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | -| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | -| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` | -| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` | -| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | -| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | -| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -| `welcome` | `ready.` | terminal banner / TUI subtitle | -| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | -| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | - -Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd. - -## The bin - -`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. - -## Example leaf `cordis.yml` - -```yaml -# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app. -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-demo' - config: - provider: deepseek - model: deepseek-v4-flash - persona: 'You are a coding assistant powered by the {{model}} model.' - ui: - mode: auto -``` - -Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". - -## Model Experience - -### Composed terminal agent request - -#### What the model sees - -Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. - -#### Token effect - -Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. - -#### KV Cache effect - -User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect. - -### Human-answer result - -#### What the model sees - -Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. - -#### Token effect - -Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. -- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. -- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts deleted file mode 100644 index 0bf66ab007..0000000000 --- a/packages/examples/stdio-demo/src/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline - * presentation, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and one pre-created agent whose exact shared - * agent/session identity the selected UI drives under its `main` display label. - * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This - * Loader plugin intentionally exposes named exports only; a default export - * would hide its `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-stdio-demo - */ - -import type { Context } from 'cordis' -import { randomUUID } from 'node:crypto' -import ConsoleExporter from '@cordisjs/plugin-logger-console' -import z from 'schemastery' -import { SessionId } from '@deepseek-ai/dsh-session' -import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' -import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiStdio from '@deepseek-ai/dsh-stdio' -import * as uiTui from '@deepseek-ai/dsh-tui' - -export const name = 'stdio-demo' -const DEFAULT_PERSISTENCE_ROOT = './.sessions' -const DEFAULT_WELCOME = 'ready.' - -/** Terminal front door selected by the app bundle. */ -export type TerminalMode = 'auto' | 'readline' | 'tui' - -/** App-level terminal selection with nested TUI presentation settings. */ -export interface UiConfig { - /** Select a concrete front door or infer it from the process streams. */ - mode?: TerminalMode - /** Settings forwarded only when the pi-tui front door is selected. */ - tui?: uiTui.TuiConfig -} - -const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto') - -/** Schemastery schema for app-level terminal selection. */ -export const UiConfigSchema: z = z.object({ - mode: terminalModeSchema, - tui: uiTui.TuiConfigSchema, -}) - -/** - * Resolve the app's terminal front door. - * @param config - app-level terminal selection. - * @param isTTY - whether both process streams are interactive TTYs. - * @returns the concrete UI package to mount. - */ -export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude { - const mode = config?.mode ?? 'auto' - if (mode === 'auto') return isTTY ? 'tui' : 'readline' - if (mode === 'tui' && !isTTY) { - throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes') - } - return mode -} - -/** - * App config: the swappable per-demo values, each routed to where the app wires - * it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through - * {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is - * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` - * is the explicit model-facing tool order (forwarded to the system-prompt plugin); - * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions - * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner and `ui` configures terminal mode/presentation. - */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent (must have a registered adapter). */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona (the system-prompt plugin's `persona` config). */ - persona?: string - /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ - toolOrder?: string[] - /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ - welcome?: string - /** Terminal front-door selection and pi-tui presentation settings. */ - ui?: UiConfig - /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-core. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ - toolTasks?: NonNullable - /** - * If set, the pre-created agent RESUMES this persisted session id instead of - * starting fresh. Sourced from an env var in the leaf `cordis.yml` - * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). - */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -export const Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), - maxParallelToolCalls: z.number().step(1).min(1), - persona: z.string(), - // The array default is forced to undefined: ABSENT means "lexicographic - // order" (the owning dsh-system-prompt schema does the same), while - // schemastery's native [] default would read as an invalid configured list. - toolOrder: z.array(z.string()).default(undefined as unknown as string[]), - tools: ToolRegistry.Config, - dshHome: z.string(), - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), - welcome: z.string().default(DEFAULT_WELCOME), - ui: UiConfigSchema, - skills: agentCore.SkillConfigSchema, - toolBash: agentCore.ToolBashConfigSchema, - toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - resumeSessionId: z.string(), - workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), -}) - -/** - * Compose the spine with one terminal front door. Persistence and user - * interaction mount first; the selected UI then waits on the exact session id - * and subscribes to config-start failures before agent-core starts it. Console - * logging is readline-only because fullscreen output belongs to pi-tui. The - * ask-user tool waits on the completed spine, and HMR remains a leaf concern. - * @param ctx - context receiving the app's child plugins. - * @param config - app configuration routed to the spine and front door. - * @param isTTY - whether both process streams are interactive TTYs. - */ -export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void { - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) - const mode = resolveTerminalMode(config.ui, isTTY) - if (mode === 'readline') ctx.plugin(ConsoleExporter) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) - ctx.plugin(UserInteractionService) - if (mode === 'tui') { - ctx.plugin(uiTui, { - ...config.ui?.tui, - welcome: config.welcome ?? DEFAULT_WELCOME, - sessionId, - }) - } else { - ctx.plugin(uiStdio, { - welcome: config.welcome ?? DEFAULT_WELCOME, - sessionId, - }) - } - ctx.plugin(agentCore, { - ...agentCore.pickSpineConfig(config), - agents: [{ - id: SessionId('main'), - provider: config.provider, - model: config.model, - cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, - }], - }) - ctx.plugin(toolAskUser) -} - -/** Compose the configured terminal front door with the agent app. */ -/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered, - and the repl-agent PTY smoke covers the interactive process path */ -export function apply(ctx: Context, config: Config): void { - composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY) -} -/* v8 ignore stop */ diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts deleted file mode 100644 index c2bb459cc9..0000000000 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { spawn } from 'node:child_process' -import { cp, mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' - -/** - * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and - * require the banner plus echo round-trip. This catches built-only early-exit and config-resolution - * failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis - * bare-plugin loading, matching the demo command. - */ - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js') - -// Symlink each required workspace package by package name so plain Node resolves its built `main`, -// matching an installed dependency rather than tsconfig paths. -const dshPackages = [ - 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', - 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', - 'session-persistence/session-persistence', - 'session-persistence/session-persistence-jsonl', 'examples/stdio-demo', 'util/paths', - 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', -] -const vendorPackages = [ - 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', - 'schemastery', 'cosmokit', -] - -async function pkgName(absDir: string): Promise { - const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } - return json.name -} - -async function installWorkspacePackageCopy(absDir: string, target: string): Promise { - await mkdir(dirname(target), { recursive: true }) - await cp(absDir, target, { - recursive: true, - filter: source => !source.split('/').includes('node_modules'), - }) -} - -/** - * Build a temporary external consumer with built workspace/vendor links and a mock-backed config. - * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less - * entries rather than treating them as import failures. - */ -async function makeConsumer( - welcome: string, - disabledBrokenEntry = false, - extraDshPackages: string[] = [], - extraEntries: string[] = [], -): Promise { - const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) - const nm = join(dir, 'node_modules') - for (const rel of [...dshPackages, ...extraDshPackages]) { - const abs = join(repoRoot, 'packages', rel) - const name = await pkgName(abs) - const target = join(nm, name) - if (extraDshPackages.includes(rel)) { - await installWorkspacePackageCopy(abs, target) - } else { - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - } - for (const v of vendorPackages) { - const abs = join(repoRoot, 'vendor', v) - const name = await pkgName(abs) - const target = join(nm, name) - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - // The example's mock model + echo tool are example-local TS plugins (Node - // 22.19+ — the engines floor — strips types natively, so plain `node` loads - // them); they import the workspace packages the symlinked node_modules now - // provides. - await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true }) - await writeFile(join(dir, 'cordis.yml'), [ - '- id: mock-llm', - ' name: \'./src/mock-llm.ts\'', - '- id: echo-tool', - ' name: \'./src/echo-tool.ts\'', - '- id: bash', - ' name: \'@deepseek-ai/dsh-bash-local\'', - '- id: stdio-agent', - ' name: \'@deepseek-ai/dsh-stdio-demo\'', - ' config:', - ' provider: mock', - ' model: mock-echo', - ' persona: \'demo\'', - ' workspaceContext: false', - ` welcome: '${welcome}'`, - ...extraEntries, - ...disabledBrokenEntry - ? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true'] - : [], - '', - ].join('\n')) - return dir -} - -/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */ -function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - // --expose-internals: the cordis Loader resolves bare plugin specifiers via - // its internal module loader (active only under this flag); demo:echo passes - // it too. NO tsx — this is the published `node lib/bin.js` path. - const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], { - cwd, - // Mock model: never calls the network, so no key needed. - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.write(`${input}\n`) - child.stdin.end() - }) -} - -let consumer: string | undefined - -afterEach(async () => { - // Windows can briefly retain released handles after exit; retry removal. - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) - consumer = undefined -}) - -describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => { - it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => { - consumer = await makeConsumer('BUILT-BIN-OK ready.') - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') - expect(stderr).not.toContain('UNHANDLED') - expect(stderr).not.toContain('without inject') - // The banner proves boot() awaited the tree (the settle-race regression would - // exit 0 with empty stdout); the round-trip proves the whole app mounted. - expect(stdout).toContain('BUILT-BIN-OK ready.') - expect(stdout).toContain('[tool call] echo') - expect(stdout).toContain('[tool result] ECHO: HI') - expect(code).toBe(0) - }, 30_000) - - it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { - // A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load - // guard must not mistake it for a failed import. The nonexistent path makes that distinction - // observable while the successful round-trip proves boot continued. - consumer = await makeConsumer('DISABLED-OK ready.', true) - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') - expect(stderr).not.toContain('failed to load') - expect(stdout).toContain('DISABLED-OK ready.') - expect(stdout).toContain('[tool result] ECHO: HI') - expect(code).toBe(0) - }, 30_000) - - it('runs two synchronously piped lines as two ordinary turns', async () => { - consumer = await makeConsumer('TWO-TURNS ready.') - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond') - expect(stderr).not.toContain('UNHANDLED') - expect(stdout).toContain('[main turn 1]') - expect(stdout).toContain('You said: "first"') - expect(stdout).toContain('[main turn 2]') - expect(stdout).toContain('You said: "second"') - expect(code).toBe(0) - }, 30_000) - - it('boots when optional spill plugins are loaded from a built consumer install', async () => { - consumer = await makeConsumer( - 'SPILL-OK ready.', - false, - ['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'], - [ - '- id: spill-local', - ' name: \'@deepseek-ai/dsh-spill-local\'', - '- id: spill-policy', - ' name: \'@deepseek-ai/dsh-spill-policy\'', - ' config:', - ' maxInlineBytes: 50000', - ], - ) - const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '') - expect(stderr).not.toContain('failed to load') - expect(stderr).not.toContain('Cannot find package') - expect(stdout).toContain('SPILL-OK ready.') - expect(code).toBe(0) - }, 30_000) - - it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config - // directory cannot break its import; the include plugin's own read must fail loud instead. - consumer = await makeConsumer('unused') - const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') - expect(code).not.toBe(0) - expect(stderr).toContain('config file not found') - }, 30_000) - - it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { - // Existing directory plus missing config exercises the include plugin's fail-loud path. - consumer = await makeConsumer('unused') - const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '') - expect(code).not.toBe(0) - expect(stderr).toContain('config file not found') - }, 30_000) -}) diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts deleted file mode 100644 index c8ca063151..0000000000 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { mkdtemp } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' - -import type { Message } from '@deepseek-ai/dsh-llm' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' -import * as stdioAgent from '../src/index.ts' - -/** - * Unit coverage for app composition and config forwarding: pre-created main agent, - * agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the - * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise - * survive namespace collapse while silently losing its schema. - */ -async function mount(config: stdioAgent.Config, withBash = false): Promise { - const ctx = new Context() - if (withBash) ctx.provide('bash', { sandboxMode: undefined }) - await ctx.plugin(stdioAgent, config) - // The app mounts its children inside apply() (not awaited there); let their - // fibers settle so the spine services + the pre-created agent are ready. - await new Promise(resolve => setTimeout(resolve, 80)) - return ctx -} - -async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise> { - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-')) - return { - local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }, - ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {}, - } -} - -async function composePrefix(ctx: Context): Promise { - const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent - const empty: Message[] = [] - return await agentEvents(ctx, agent).waterfall( - 'agent/session-prefix', empty, new AbortController().signal, - () => Promise.resolve(empty), - ) -} - -async function withIsolatedSkillHomes(run: () => Promise): Promise { - const oldDshHome = process.env.DSH_HOME - const oldAgentsHome = process.env.DSH_AGENTS_HOME - const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-')) - process.env.DSH_HOME = join(home, '.dsh') - process.env.DSH_AGENTS_HOME = join(home, '.agents') - try { - return await run() - } finally { - if (oldDshHome === undefined) { - delete process.env.DSH_HOME - } else { - process.env.DSH_HOME = oldDshHome - } - if (oldAgentsHome === undefined) { - delete process.env.DSH_AGENTS_HOME - } else { - process.env.DSH_AGENTS_HOME = oldAgentsHome - } - } -} - -describe('dsh-stdio-demo app', () => { - it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => { - expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline') - expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui') - expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline') - expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui') - expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout') - }) - - it('binds only the selected terminal package to the app-owned exact session identity', () => { - const calls: Array<{ name: string; config: unknown }> = [] - const ctx = { - plugin(plugin: { name?: string }, config?: unknown) { - calls.push({ name: plugin.name ?? '', config }) - }, - } as unknown as Context - - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', - model: 'mock', - workspaceContext: false, - welcome: 'TUI ready', - ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, - }, true) - expect(calls.map(call => call.name)).toContain('ui-tui') - expect(calls.map(call => call.name)).not.toContain('ui-stdio') - expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') - const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string } - expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) - expect(tuiConfig.sessionId).toMatch(/^main-session-/) - const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as { - agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }> - } - expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId }) - - calls.length = 0 - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', - model: 'mock', - resumeSessionId: 'persisted-session', - workspaceContext: false, - ui: { mode: 'tui' }, - }, true) - expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({ - sessionId: 'persisted-session', welcome: 'ready.', - }) - expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0]) - .toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' }) - - calls.length = 0 - stdioAgent.composeTerminalApp(ctx, { - provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' }, - }, false) - expect(calls.map(call => call.name)).toContain('ui-stdio') - expect(calls.map(call => call.name)).toContain('ConsoleExporter') - expect(calls.map(call => call.name)).not.toContain('ui-tui') - }) - - it('composes the spine + front-door cluster and pre-creates the main agent', async () => { - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) - // The spine services (brought up by the agent-spine-demo bundle) are all present. - expect(ctx.get('agents')).toBeDefined() - expect(ctx.get('agentLoop')).toBeDefined() - expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('userInteraction')).toBeDefined() - expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The sole pre-created agent the UI drives. `main` is its stable config - // label; each fresh process mints a durable combined agent/session id. - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - const agent = ctx.get('agents')?.list()[0] - expect(agent).toBeDefined() - expect(agent?.id).toBe(agent?.session.id) - expect(agent?.id).toMatch(/^main-session-/) - expect(agent?.session.header.cwd).toBe(process.cwd()) - await ctx.fiber.dispose() - }) - - it('normalizes an empty resume id to a fresh exact app identity', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - resumeSessionId: '', - persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - const agent = ctx.get('agents')?.list()[0] - expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect(agent?.id).toBe(agent?.session.id) - await ctx.fiber.dispose() - }) - - it('defaults persistenceRoot and welcome when omitted', async () => { - // Direct apply (NOT via ctx.plugin, which validates+defaults the config - // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on - // apply()'s last two lines are the ones that fire — covering a - // schema-bypassing direct-mount caller. - const ctx = new Context() - // No persona: covers the omitted-persona forwarding branch too. - stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) - await ctx.fiber.dispose() - }) - - it('forwards explicit project-instruction controls to the bundled spine', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - persona: 'hi', - persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context', - workspaceContext: false, - }) - await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) - expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) - await ctx.fiber.dispose() - }) - - it('uses default skill config when apply is called directly without skills', async () => { - await withIsolatedSkillHomes(async () => { - const ctx = new Context() - stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false }) - await new Promise(resolve => setTimeout(resolve, 80)) - expect(ctx.skills).toBeDefined() - expect(await ctx.skills.list()).toEqual([]) - await ctx.fiber.dispose() - }) - }) - - it('forwards resumeSessionId onto the pre-created agent when set', async () => { - // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no agent registers — - // the branch that maps resumeSessionId through is what this covers. - const ctx = await mount({ - provider: 'mock', - model: 'mock', - persona: 'hi', - persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume', - resumeSessionId: 'no-such-session', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - expect(ctx.get('agents')?.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('forwards skill config and dshHome into agent-spine-demo', async () => { - const skills = await isolatedSkillsConfig(6) - const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false }) - ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' }) - expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...') - await ctx.fiber.dispose() - }) - - it('forwards maxParallelToolCalls to the bundled agent loop', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - maxParallelToolCalls: 3, - persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel', - skills: await isolatedSkillsConfig(), - workspaceContext: false, - }) - expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) - await ctx.fiber.dispose() - }) - - it('forwards bundled tool config into agent-core', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - workspaceContext: false, - toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, - skills: await isolatedSkillsConfig(), - }, true) - const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') - expect(Object.keys((bash!.parameters as { properties: Record }).properties)) - .not.toContain('run_in_background') - await ctx.fiber.dispose() - }) - - it('exposes its name and Config schema', () => { - expect(stdioAgent.name).toBe('stdio-demo') - expect(stdioAgent.Config).toBeDefined() - }) - - it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => { - const ctx = await mount({ - provider: 'mock', - model: 'mock', - toolOrder: ['zulu', TOOL_ORDER_REST], - persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order', - workspaceContext: false, - }) - // The bundle's own bash tools pend on the absent `ctx.bash` executor in - // this providerless mount, so register two plain tools to order. - for (const name of ['alpha', 'zulu']) { - ctx.get('tools')!.register({ - name, - description: name, - parameters: {}, - execute: async () => [], - }) - } - const assembly = await ctx.get('systemPrompt')!.assemble() - expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output']) - await ctx.fiber.dispose() - }) - - it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { - // A default export would make `unwrapExports` collapse this inject-less namespace and silently - // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. - expect('default' in stdioAgent).toBe(false) - expect(typeof stdioAgent.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(stdioAgent) as Record - expect(unwrapped).toBe(stdioAgent) - expect(unwrapped.name).toBe('stdio-demo') - expect(unwrapped.Config).toBeDefined() - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md new file mode 100644 index 0000000000..1e20545e70 --- /dev/null +++ b/packages/examples/tui-demo/README.md @@ -0,0 +1,101 @@ +# @deepseek-ai/dsh-tui-demo + +The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`. + +Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback. + +## What it bakes in + +| Plugin | Why it is here | +|---|---| +| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent | +| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | +| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | +| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | +| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | + +Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `provider` | required | Configured `main` agent provider | +| `model` | required | Configured `main` agent model | +| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap | +| `persona` | — | System-prompt persona template | +| `toolOrder` | lexicographic | Explicit model-facing tool order | +| `tools` | owner default | Tool presentation mode | +| `dshHome` | owner default | Harness home used by bash and skills | +| `skills` | owner defaults | Skill registry, local provider, and tool config | +| `toolBash` | owner defaults | Model-facing bash tool config | +| `toolTasks` | owner defaults | Background-task control-tool config, or `false` | +| `workspaceContext` | required | Workspace-instruction config, or `false` | +| `persistenceRoot` | `./.sessions` | JSONL persistence root | +| `welcome` | `ready.` | TUI subtitle | +| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | +| `resumeSessionId` | — | Exact persisted session to resume | + +Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. + +## The bin + +`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. Bare package specifiers require `node --expose-internals` or the Loader's optional native fallback; the repository scripts use `--expose-internals`. + +## Example leaf + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: bash + name: '@deepseek-ai/dsh-bash-local' +- id: tui-agent + name: '@deepseek-ai/dsh-tui-demo' + config: + provider: deepseek + model: deepseek-v4-flash + workspaceContext: + maxBytes: 65536 + welcome: 'Coding agent ready.' + ui: + showReasoning: true +``` + +## Model Experience + +### Interactive terminal turn + +#### What the model sees + +Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible. + +#### Token effect + +User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens. + +#### KV Cache effect + +Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token. + +### Human-question answer + +#### What the model sees + +`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only. + +#### Token effect + +Only the completed or failed tool result adds retained tokens. + +#### KV Cache effect + +Append-only; the answer follows the reusable request prefix. + +## Known Limitations and Deferred Work + +- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`. +- **One configured terminal session** — the transcript and editor bind to one exact session id. +- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition. +- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/tui-demo/package.json similarity index 84% rename from packages/examples/stdio-demo/package.json rename to packages/examples/tui-demo/package.json index 94554e3f8e..3bdc880023 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -1,13 +1,13 @@ { - "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", + "name": "@deepseek-ai/dsh-tui-demo", + "description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", "bin": { - "dsh-stdio-demo": "lib/bin.js" + "dsh-tui-demo": "lib/bin.js" }, "exports": { ".": { @@ -32,7 +32,6 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", @@ -41,7 +40,6 @@ "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-stdio": "^0.0.1", "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -52,7 +50,6 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -62,7 +59,6 @@ "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-stdio": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/tui-demo/src/bin.ts similarity index 66% rename from packages/examples/stdio-demo/src/bin.ts rename to packages/examples/tui-demo/src/bin.ts index 3d8a0c2a33..237e4391b5 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/packages/examples/tui-demo/src/bin.ts @@ -1,14 +1,14 @@ #!/usr/bin/env node /** - * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the + * Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs. - * @module @deepseek-ai/dsh-stdio-demo/bin + * dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs. + * @module @deepseek-ai/dsh-tui-demo/bin */ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -const NAME = 'dsh-stdio-demo' +const NAME = 'dsh-tui-demo' /* v8 ignore start -- thin self-executing composition over the unit-tested dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts new file mode 100644 index 0000000000..493e3431d4 --- /dev/null +++ b/packages/examples/tui-demo/src/index.ts @@ -0,0 +1,121 @@ +/** + * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) + * plus JSONL persistence, keyboard-backed user interaction, and one pre-created + * agent whose exact session identity the TUI drives. Swappable adapters, + * executors, optional tools, and HMR stay in the leaf. This Loader plugin + * intentionally exposes named exports only; a default export would hide its + * `Config` schema (see docs/postmortem/0001). + * @module @deepseek-ai/dsh-tui-demo + */ + +import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' +import z from 'schemastery' +import { SessionId } from '@deepseek-ai/dsh-session' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' +import * as uiTui from '@deepseek-ai/dsh-tui' + +export const name = 'tui-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_WELCOME = 'ready.' + +/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the `main` agent. */ + provider: string + /** Model name for the `main` agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** TUI subtitle rendered on start. Defaults to `ready.`. */ + welcome?: string + /** Full-screen TUI presentation settings. */ + ui?: uiTui.TuiConfig + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** Persisted session id to resume instead of creating a fresh session. */ + resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} + +// Each front door keeps a complete Loader schema so its deployment contract is +// readable without a cross-package config facade. +/* jscpd:ignore-start */ +export const Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), + persona: z.string(), + // Absent means lexicographic order; schemastery's native array default is []. + toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRegistry.Config, + dshHome: z.string(), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + welcome: z.string().default(DEFAULT_WELCOME), + ui: uiTui.TuiConfigSchema, + skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), + resumeSessionId: z.string(), + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), +}) +/* jscpd:ignore-end */ + +/** + * Compose the spine, TUI, JSONL persistence, and user-question tool around one + * exact fresh or resumed session identity. The TUI subscribes to startup + * failures before the spine creates the agent. + * @param ctx - context receiving the app's child plugins. + * @param config - validated app configuration. + */ +export function composeTuiApp(ctx: Context, config: Config): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(UserInteractionService) + ctx.plugin(uiTui, { + ...config.ui, + welcome: config.welcome ?? DEFAULT_WELCOME, + sessionId, + }) + ctx.plugin(agentCore, { + ...agentCore.pickSpineConfig(config), + agents: [{ + id: SessionId('main'), + provider: config.provider, + model: config.model, + cwd: process.cwd(), + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, + }], + }) + ctx.plugin(toolAskUser) +} + +/** + * Compose the configured full-screen terminal app. + * @param ctx - context receiving the app's child plugins. + * @param config - validated app configuration. + */ +export function apply(ctx: Context, config: Config): void { + composeTuiApp(ctx, config) +} diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts new file mode 100644 index 0000000000..a5e4e56b77 --- /dev/null +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import * as tuiAgent from '../src/index.ts' + +interface PluginCall { + readonly name: string + readonly config: unknown +} + +function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { + const calls: PluginCall[] = [] + const ctx = { + plugin(plugin: { name?: string }, config?: unknown) { + calls.push({ name: plugin.name ?? '', config }) + }, + } as unknown as Context + return { ctx, calls } +} + +describe('dsh-tui-demo app', () => { + it('composes the TUI cluster around one fresh exact session identity', () => { + const { ctx, calls } = recordingContext() + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + maxParallelToolCalls: 3, + persona: 'test persona', + toolOrder: ['zulu', TOOL_ORDER_REST], + tools: { mode: 'code' }, + dshHome: '/tmp/dsh-home', + persistenceRoot: '/tmp/tui-sessions', + welcome: 'TUI ready', + ui: { color: false, maxToolOutputLines: 3 }, + skills: { tool: { catalogDescriptionMaxLength: 8 } }, + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + workspaceContext: false, + }) + + expect(calls.map(call => call.name)).toEqual([ + 'SessionPersistenceJsonl', + 'UserInteractionService', + 'ui-tui', + 'agent-spine-demo', + 'tool-ask-user', + ]) + expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions' }) + const tuiConfig = calls[2]?.config as { sessionId: string } + expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) + expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) + const spineConfig = calls[3]?.config as { + readonly agents: Array> + readonly maxParallelToolCalls: number + readonly persona: string + readonly toolOrder: string[] + readonly tools: { mode: string } + } + expect(spineConfig).toMatchObject({ + maxParallelToolCalls: 3, + persona: 'test persona', + toolOrder: ['zulu', TOOL_ORDER_REST], + tools: { mode: 'code' }, + }) + expect(spineConfig.agents[0]).toMatchObject({ + id: 'main', + provider: 'mock', + model: 'mock-model', + cwd: process.cwd(), + sessionId: tuiConfig.sessionId, + }) + }) + + it('resumes the configured session and applies runtime defaults', () => { + const { ctx, calls } = recordingContext() + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + resumeSessionId: 'persisted-session', + workspaceContext: false, + }) + + expect(calls[0]?.config).toEqual({ root: './.sessions' }) + expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' }) + expect((calls[3]?.config as { agents: Array> }).agents[0]).toMatchObject({ + id: 'main', + resumeSessionId: 'persisted-session', + }) + }) + + it('normalizes an empty resume id and routes apply through the same composition', () => { + const { ctx, calls } = recordingContext() + tuiAgent.apply(ctx, { + provider: 'mock', + model: 'mock-model', + resumeSessionId: '', + workspaceContext: false, + }) + + const tuiConfig = calls[2]?.config as { sessionId: string } + expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect((calls[3]?.config as { agents: Array> }).agents[0]) + .toMatchObject({ sessionId: tuiConfig.sessionId }) + }) + + it('has the namespace-plugin export shape so the Loader keeps its schema', () => { + expect(tuiAgent.name).toBe('tui-demo') + expect(tuiAgent.Config).toBeDefined() + expect('default' in tuiAgent).toBe(false) + expect(typeof tuiAgent.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(tuiAgent) as Record + expect(unwrapped).toBe(tuiAgent) + expect(unwrapped.name).toBe('tui-demo') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json similarity index 88% rename from packages/examples/stdio-demo/tsconfig.json rename to packages/examples/tui-demo/tsconfig.json index fc6711ffb9..2e92bb3b2b 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../../ui/app-boot" }, - { - "path": "../../../vendor/logger-console" - }, { "path": "../../core/agent" }, @@ -38,9 +35,6 @@ { "path": "../../ui/user-interaction" }, - { - "path": "../../ui/stdio" - }, { "path": "../../ui/tui" }, diff --git a/packages/examples/stdio-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts similarity index 87% rename from packages/examples/stdio-demo/tsdown.config.ts rename to packages/examples/tui-demo/tsdown.config.ts index 53797cdd79..b3929c6d76 100644 --- a/packages/examples/stdio-demo/tsdown.config.ts +++ b/packages/examples/tui-demo/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown' /** - * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` + * tui-demo ships two entries: the plugin (`index`) and the CLI `bin` * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. * The root tsdown builds only `lib/types/index.js`, so this override adds * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 897159bd7c..2b156f7fb5 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -61,7 +61,7 @@ function createProgram(): Command { .option('--base-url ') .option('--api-key ') .option('--model ') - .addOption(new Option('--interface ').choices(['acp', 'stdio', 'embed'])) + .addOption(new Option('--interface ').choices(['acp', 'tui', 'embed'])) .addOption(new Option('--pm ').choices(['npm', 'pnpm', 'yarn'])) .addOption(new Option('--install').default(undefined)) .addOption(new Option('--no-install').default(undefined)) diff --git a/packages/sdk/create-sdk/src/create-questions.ts b/packages/sdk/create-sdk/src/create-questions.ts index c53e6e227e..193f1fdc25 100644 --- a/packages/sdk/create-sdk/src/create-questions.ts +++ b/packages/sdk/create-sdk/src/create-questions.ts @@ -169,10 +169,10 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep[] = [ message: 'Run interface', options: [ { value: 'acp', label: 'ACP server' }, - { value: 'stdio', label: 'Terminal REPL' }, + { value: 'tui', label: 'Terminal TUI' }, { value: 'embed', label: 'Embedded context' }, ], - initialValue: 'stdio', + initialValue: 'tui', }), prefilled: state => state.args.runInterface, apply: (state, value) => { state.runInterface = value }, diff --git a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl index 32f4d5c6d2..1842571cdd 100644 --- a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl @@ -6,7 +6,7 @@ Options: --base-url --api-key --model - --interface + --interface --pm --install / --no-install --config diff --git a/packages/sdk/create-sdk/tests/create.snapshot.ts b/packages/sdk/create-sdk/tests/create.snapshot.ts index a5ea46db53..08aa4a5a73 100644 --- a/packages/sdk/create-sdk/tests/create.snapshot.ts +++ b/packages/sdk/create-sdk/tests/create.snapshot.ts @@ -179,12 +179,12 @@ describe('create-sdk terminal contract', () => { "message": "DeepSeek API key", }, { - "initialValue": "stdio", + "initialValue": "tui", "kind": "select", "message": "Run interface", "options": [ "ACP server", - "Terminal REPL", + "Terminal TUI", "Embedded context", ], }, diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index c7c74c9659..f6dc8e1709 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -151,7 +151,7 @@ describe('create arguments', () => { expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'") expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom') expect(parseCreateArgs(['--help']).help).toBe(true) - expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, stdio, embed') + expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed') expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'") expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments') }) @@ -208,7 +208,7 @@ describe('CreateWizard and scaffolder', () => { '--provider=deepseek', '--api-key=deepseek-key', '--model=deepseek-v4-flash', - '--interface=stdio', + '--interface=tui', '--pm=npm', '--no-install', '--link-workspace', @@ -247,7 +247,7 @@ describe('CreateWizard and scaffolder', () => { const resolved = await new CreateWizard({ args: parseCreateArgs([ 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key', - '--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install', + '--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install', ]), port: new HeadlessPromptPort(), cwd, @@ -275,7 +275,7 @@ describe('CreateWizard and scaffolder', () => { await expect(new CreateWizard({ args: parseCreateArgs([ 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k', - '--model=m', '--interface=stdio', '--pm=npm', '--no-install', + '--model=m', '--interface=tui', '--pm=npm', '--no-install', ]), port: new HeadlessPromptPort(), cwd, diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index e4ff11af20..8e7793ab8f 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -29,7 +29,7 @@ const ID = featureId('app') function appProjectResources( profile: ProjectProfile, - runInterface: 'acp' | 'stdio' | 'embed', + runInterface: 'acp' | 'tui' | 'embed', ): readonly ProjectResource[] { const context = createProjectTemplateContext(profile, runInterface) const scripts = createAppPackageScripts(context) @@ -43,10 +43,10 @@ function appProjectResources( } class AppOption extends FeatureOption { - override readonly id: 'acp' | 'stdio' | 'embed' + override readonly id: 'acp' | 'tui' | 'embed' override readonly label: string - constructor(id: 'acp' | 'stdio' | 'embed', label: string) { + constructor(id: 'acp' | 'tui' | 'embed', label: string) { super() this.id = id this.label = label @@ -56,7 +56,7 @@ class AppOption extends FeatureOption { override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] - case 'stdio': return [{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' }] + case 'tui': return [{ id: 'tui', name: '@deepseek-ai/dsh-tui' }] case 'embed': return [] } } @@ -65,7 +65,7 @@ class AppOption extends FeatureOption { override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') - && !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-stdio') + && !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-tui') } override contribution(profile: ProjectProfile): ProjectContribution { @@ -83,7 +83,7 @@ class AppOption extends FeatureOption { config: { model: profile.runtime.model }, }, ['model'], config => requiredString(config, 'model')), ]) - case 'stdio': + case 'tui': return new ProjectContribution([ ...appProjectResources(profile, this.id), ...npmCordisConfigEntry(ID, { @@ -91,10 +91,10 @@ class AppOption extends FeatureOption { name: '@deepseek-ai/dsh-user-interaction', }), ...npmCordisConfigEntry(ID, { - id: 'stdio', - name: '@deepseek-ai/dsh-stdio', + id: 'tui', + name: '@deepseek-ai/dsh-tui', config: { - welcome: 'agent REPL ready. Give it a coding task.', + welcome: 'TUI agent ready. Give it a coding task.', sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'), }, }, ['welcome', 'sessionId'], config => [ @@ -108,7 +108,7 @@ class AppOption extends FeatureOption { } } -/** Required app selection represented by acp, stdio, or embed options. */ +/** Required app selection represented by ACP, TUI, or embed options. */ export class AppFeature extends ExclusiveOptionFeature { override readonly id = ID override readonly summary = 'Run interface' @@ -116,7 +116,7 @@ export class AppFeature extends ExclusiveOptionFeature { override readonly requires = [featureId('spine')] override readonly options = [ new AppOption('acp', 'ACP server'), - new AppOption('stdio', 'Terminal REPL'), + new AppOption('tui', 'Terminal TUI'), new AppOption('embed', 'Embedded context'), ] diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index c4043e7d59..29889b438e 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -347,7 +347,7 @@ config: id: 'ask-user', summary: 'Ask the user from the model loop', mode: 'single', - supportedInterfaces: ['acp', 'stdio'], + supportedInterfaces: ['acp', 'tui'], options: [{ id: 'default', label: 'ask_user_question tool', diff --git a/packages/sdk/helper/src/features/define-feature.ts b/packages/sdk/helper/src/features/define-feature.ts index 7c90a2853a..6b726d42d6 100644 --- a/packages/sdk/helper/src/features/define-feature.ts +++ b/packages/sdk/helper/src/features/define-feature.ts @@ -250,7 +250,7 @@ class DefinedFeature extends Feature { this.required = spec.required ?? false this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id)) this.suggests = (spec.suggests ?? []).map(featureId) - this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'stdio', 'embed'] + this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'tui', 'embed'] } override defaultOptions(): readonly string[] { diff --git a/packages/sdk/helper/src/features/feature.ts b/packages/sdk/helper/src/features/feature.ts index 1335d8e29b..b77deb8093 100644 --- a/packages/sdk/helper/src/features/feature.ts +++ b/packages/sdk/helper/src/features/feature.ts @@ -113,7 +113,7 @@ export abstract class Feature { /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] /** Front doors under which this feature is meaningful. */ - readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'stdio', 'embed'] + readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'tui', 'embed'] /** * Options selected when installation has no override. diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 0d0a1cb6dd..d027d74e08 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -549,7 +549,7 @@ export class ProjectEditSession implements FeatureProjectView { private finalProfile(): ProjectProfile { const runInterface = this.states.get(featureId('app'))?.selection?.options[0] - if (runInterface !== 'acp' && runInterface !== 'stdio' && runInterface !== 'embed') return this.profile + if (runInterface !== 'acp' && runInterface !== 'tui' && runInterface !== 'embed') return this.profile return { ...this.profile, runInterface } } diff --git a/packages/sdk/helper/src/project/sdk-project.ts b/packages/sdk/helper/src/project/sdk-project.ts index cd55ffe2e5..a24a08b3df 100644 --- a/packages/sdk/helper/src/project/sdk-project.ts +++ b/packages/sdk/helper/src/project/sdk-project.ts @@ -42,7 +42,7 @@ const OPTIONAL_DOCUMENTS = [ function runInterface(entries: readonly CordisConfigEntry[]): RunInterface { if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp' - if (entries.some(entry => entry.name === '@deepseek-ai/dsh-stdio')) return 'stdio' + if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui' return 'embed' } @@ -146,7 +146,7 @@ export class SdkProject { static create(root: string, request: ProjectCreationRequest): SdkProject { const app = request.features.find(selection => selection.id === 'app') const selectedInterface = app?.options[0] - if (selectedInterface !== 'acp' && selectedInterface !== 'stdio' && selectedInterface !== 'embed') { + if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') { throw new Error('project creation requires one app feature option') } const profile: ProjectProfile = { diff --git a/packages/sdk/helper/src/project/types.ts b/packages/sdk/helper/src/project/types.ts index 44d06d508c..11fca01b8d 100644 --- a/packages/sdk/helper/src/project/types.ts +++ b/packages/sdk/helper/src/project/types.ts @@ -9,7 +9,7 @@ import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' /** Runtime front door selected for a generated project. */ -export type RunInterface = 'acp' | 'stdio' | 'embed' +export type RunInterface = 'acp' | 'tui' | 'embed' /** Values shared by the required provider and app features. */ interface ProjectRuntimeOptions { diff --git a/packages/sdk/helper/src/templates/assets/README.md.tpl b/packages/sdk/helper/src/templates/assets/README.md.tpl index 0033c8e0d4..bdaef06c4e 100644 --- a/packages/sdk/helper/src/templates/assets/README.md.tpl +++ b/packages/sdk/helper/src/templates/assets/README.md.tpl @@ -9,7 +9,7 @@ Built with the DeepSeek Harness SDK using the {{model}} model. Run `{{packageManager}} start` and configure your ACP client to launch this project. Standard output is reserved for ACP JSON-RPC. {{else}} -{{#if isStdio}} +{{#if isTui}} ## Run in a terminal Run `{{packageManager}} start` to start the interactive agent. diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index a79818908c..311c6746cf 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -8,18 +8,18 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' /** Boot this project's cordis.yml when invoked by dsh-scripts. */ export async function main(boot: SdkBootContext) { -{{#if isStdio}} +{{#if isTui}} const model = boot.args.model - if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=') + if (typeof model !== 'string' || model.length === 0) throw new Error('TUI startup requires --model=') const resume = boot.args.resume if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { - throw new Error('stdio startup requires --resume=') + throw new Error('TUI startup requires --resume=') } const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`) process.env.DSH_SDK_SESSION_ID = sessionId {{/if}} const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) -{{#if isStdio}} +{{#if isTui}} try { if (resume === undefined) { await ctx.agents.create({ @@ -37,7 +37,7 @@ export async function main(boot: SdkBootContext) { try { await ctx.fiber.dispose() } catch (disposeError) { - throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed') + throw new AggregateError([error, disposeError], 'TUI startup and cleanup failed') } throw error } diff --git a/packages/sdk/helper/src/templates/project-template.ts b/packages/sdk/helper/src/templates/project-template.ts index b214126b6e..afcf820ec2 100644 --- a/packages/sdk/helper/src/templates/project-template.ts +++ b/packages/sdk/helper/src/templates/project-template.ts @@ -20,7 +20,7 @@ export interface ProjectTemplateContext { model: string modelLiteral: string isAcp: boolean - isStdio: boolean + isTui: boolean isEmbed: boolean packageManager: PackageManagerName installArgs: string @@ -60,7 +60,7 @@ export function createProjectTemplateContext( model: profile.runtime.model, modelLiteral: JSON.stringify(profile.runtime.model), isAcp: runInterface === 'acp', - isStdio: runInterface === 'stdio', + isTui: runInterface === 'tui', isEmbed: runInterface === 'embed', packageManager: profile.packageManager.name, installArgs: profile.packageManager.installCommand().join(' '), @@ -105,7 +105,7 @@ export function createAppProjectArtifacts( /** Build package scripts owned by the selected app feature option. */ export function createAppPackageScripts(context: ProjectTemplateContext): Readonly> { - const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : '' + const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : '' return { dev: `dsh-sdk dev index.ts${modelArg}`, start: `dsh-sdk start index.js${modelArg}`, diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 7181820725..1e4b944f00 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -243,7 +243,7 @@ overrides: expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory') expect(createBaselineProjectArtifacts({ name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn', - isAcp: false, isStdio: false, isEmbed: true, + isAcp: false, isTui: false, isEmbed: true, installArgs: 'install', buildArgs: 'build', }).map(document => document.relativePath)).toContain('.yarnrc.yml') expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name') diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 80bd578e69..18540d4e67 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record { expect(acp.readEnvironment('.env', 'KEY')).toBe('value') expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow() expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile) - const stdio = await make('dsh-open-stdio', {}, `- id: provider + const tui = await make('dsh-open-tui', {}, `- id: provider name: '@deepseek-ai/dsh-llm-deepseek' config: { models: [provider-model] } -- id: stdio - name: '@deepseek-ai/dsh-stdio' +- id: tui + name: '@deepseek-ai/dsh-tui' `, { 'yarn.lock': '' }) - expect(stdio.profile.runInterface).toBe('stdio') - expect(stdio.profile.runtime.model).toBe('provider-model') - expect(stdio.profile.packageManager.name).toBe('yarn') - expect(stdio.profile.name).toBe(stdio.root.split('/').at(-1)) + expect(tui.profile.runInterface).toBe('tui') + expect(tui.profile.runtime.model).toBe('provider-model') + expect(tui.profile.packageManager.name).toBe('yarn') + expect(tui.profile.name).toBe(tui.root.split('/').at(-1)) const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' }) expect(pnpm.profile.packageManager.name).toBe('pnpm') const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n') @@ -134,8 +134,8 @@ describe('SdkProject and ProjectEditSession', () => { expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app') await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n')) .rejects.toThrow('invalid packageManager field') - const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: stdio - name: '@deepseek-ai/dsh-stdio' + const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui + name: '@deepseek-ai/dsh-tui' config: { model: '' } - id: provider name: '@deepseek-ai/dsh-llm-deepseek' @@ -172,7 +172,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') expect(index).toContain('resumeSessionId: sessionId') expect(index).toContain('await ctx.fiber.dispose()') - expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')") + expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')") expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', @@ -181,12 +181,12 @@ describe('SdkProject and ProjectEditSession', () => { config: 'dsh-sdk config', }) expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({ + expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({ source: 'process.env.DSH_SDK_SESSION_ID', }) expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') - expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') + expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2') @@ -211,13 +211,13 @@ describe('SdkProject and ProjectEditSession', () => { expect(app.selection).toEqual(selection('app', ['embed'])) expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(committed.cordis.entry('acp')).toBeUndefined() - expect(committed.cordis.entry('stdio')).toBeUndefined() + expect(committed.cordis.entry('tui')).toBeUndefined() }) it('emits the sandbox workspace-write example as inactive Cordis config', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-')) temporary.push(root) - const creation = request([], [], 'stdio', 'sandbox') + const creation = request([], [], 'tui', 'sandbox') const project = SdkProject.create(root, creation) const registry = createBuiltinRegistry(project.profile) const edit = project.edit(registry) @@ -306,7 +306,7 @@ describe('SdkProject and ProjectEditSession', () => { const modifiedRegistry = createBuiltinRegistry(modified.profile) expect(() => { modified.edit(modifiedRegistry).configureFeature( modifiedRegistry.get(featureId('app')), - selection('app', ['stdio']), + selection('app', ['tui']), ) }).toThrow('feature-owned file was modified: README.md') const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8')) @@ -349,7 +349,7 @@ describe('SdkProject and ProjectEditSession', () => { const edit = project.edit(registry) edit.setCustomPluginDisabled('sample', true) expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true) - expect(() => { edit.setCustomPluginDisabled('stdio', true) }).toThrow('builtin feature') + expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature') const next = (await edit.commit()).project const enable = next.edit(createBuiltinRegistry(next.profile)) enable.setCustomPluginDisabled('sample', false) @@ -444,8 +444,8 @@ describe('SdkProject and ProjectEditSession', () => { } const internals = edit as unknown as Internals const collidingEntry: ProjectResource = { - kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:stdio'), - entry: { id: 'stdio', name: 'other-package' }, ownedConfigKeys: [], + kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'), + entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [], } expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by') const existingFile: ProjectResource = { @@ -797,7 +797,7 @@ describe('extension points', () => { }) expect(exclusive.defaultOptions(profile)).toEqual(['one']) expect(exclusive.isApplicable(profile)).toBe(true) - expect(exclusive.isApplicable({ ...profile, runInterface: 'stdio' })).toBe(false) + expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false) expect(exclusive.requirements(selection('defined', ['one']))).toEqual([ { id: 'base' }, { id: 'option', options: ['required'] }, ]) @@ -811,7 +811,7 @@ describe('extension points', () => { expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([]) expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3) expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong') - expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'stdio' })) + expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' })) .toThrow('not available') expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown') expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one') @@ -819,7 +819,7 @@ describe('extension points', () => { id: 'fixed', summary: 'Fixed', mode: 'single', options: [option], }])).toHaveLength(2) expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature') - expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'stdio' })) + expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' })) .toBeUndefined() class Unsupported extends FixedFeature { override readonly id = featureId('unsupported') @@ -897,10 +897,10 @@ describe('extension points', () => { resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') expect(acpEntry?.entry.id).toBe('acp') expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) - const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources + const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources .find((resource): resource is CordisConfigEntryResource => - resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio') - expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ + resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui') + expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ 'sessionId must be a non-empty string', ]) const embedOption = app.options.find(option => option.id === 'embed') @@ -910,7 +910,7 @@ describe('extension points', () => { ]) expect(embedOption?.matchesConfigEntries([ { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' }, - { id: 'stdio', name: '@deepseek-ai/dsh-stdio' }, + { id: 'tui', name: '@deepseek-ai/dsh-tui' }, ], profile)).toBe(false) const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources .find((resource): resource is CordisConfigEntryResource => diff --git a/packages/sdk/helper/tests/questions.spec.ts b/packages/sdk/helper/tests/questions.spec.ts index 5eb205075f..dc9e734ac5 100644 --- a/packages/sdk/helper/tests/questions.spec.ts +++ b/packages/sdk/helper/tests/questions.spec.ts @@ -376,7 +376,7 @@ describe('feature configurator', () => { name: 'demo', description: 'demo', runtime: { model: 'deepseek-v4-flash' }, - runInterface: 'stdio', + runInterface: 'tui', packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1', } diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 016d9d09b8..408a9b9639 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -56,7 +56,7 @@ function targetRunInterface( desired: ReadonlyMap>, ): RunInterface { const selected = desired.get('feature:app')?.choices[0] - return selected === 'acp' || selected === 'stdio' || selected === 'embed' ? selected : current + return selected === 'acp' || selected === 'tui' || selected === 'embed' ? selected : current } /** Reconcile one tree selection into domain commands, then review and commit once. */ diff --git a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap index 31e21dd8a9..14f74b0f85 100644 --- a/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap +++ b/packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap @@ -90,8 +90,8 @@ Change file: package.json }, { "default": true, - "label": "Terminal REPL", - "value": "stdio", + "label": "Terminal TUI", + "value": "tui", }, { "default": false, diff --git a/packages/sdk/scripts/tests/config.snapshot.ts b/packages/sdk/scripts/tests/config.snapshot.ts index 80a79a5f20..e6047c8562 100644 --- a/packages/sdk/scripts/tests/config.snapshot.ts +++ b/packages/sdk/scripts/tests/config.snapshot.ts @@ -94,7 +94,7 @@ async function baseProject(): Promise { features: [ { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } }, { id: featureId('bash'), options: ['local'] }, - { id: featureId('app'), options: ['stdio'] }, + { id: featureId('app'), options: ['tui'] }, { id: featureId('persistence'), options: ['jsonl'] }, ], localPlugins: [], diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8b887d74db..a6ec4f4a4f 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -85,7 +85,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () => function creation( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'stdio' | 'embed' = 'embed', + app: 'acp' | 'tui' | 'embed' = 'embed', ): ProjectCreationRequest { return { name: 'config-agent', @@ -107,7 +107,7 @@ function creation( async function committedProject( extra: ProjectCreationRequest['features'] = [], localPlugins: readonly LocalPluginBlueprint[] = [], - app: 'acp' | 'stdio' | 'embed' = 'embed', + app: 'acp' | 'tui' | 'embed' = 'embed', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-')) temporary.push(root) @@ -525,7 +525,7 @@ describe('ConfigWorkflow', () => { const workflow = new ConfigWorkflow(new QueuePort([ [ { value: 'feature:provider', choices: ['custom'] }, - { value: 'feature:app', choices: ['stdio'] }, + { value: 'feature:app', choices: ['tui'] }, { value: 'feature:persistence', choices: ['jsonl'] }, ], 'https://provider.example/v1', @@ -536,7 +536,7 @@ describe('ConfigWorkflow', () => { const provider = result.commit?.project.cordis.entry('llm-pi-ai') expect(provider?.config?.apiKey).toBeDefined() expect(provider?.config?.baseURL).toBe('https://provider.example/v1') - expect(result.commit?.project.cordis.entry('stdio')).toBeDefined() + expect(result.commit?.project.cordis.entry('tui')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined() expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined() }) diff --git a/packages/support/README.md b/packages/support/README.md index 433d6b3cdb..045b69d390 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -10,4 +10,4 @@ Packages that exist to serve development, testing, and the examples rather than | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts index 20520e6fb6..77a0791516 100644 --- a/packages/support/loader-smoke/tests/example-launch.spec.ts +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -5,7 +5,7 @@ import { resolveExampleMode, } from '@deepseek-ai/dsh-loader-smoke' -const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts' +const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts' const TSCONFIG = '/repo/tsconfig.json' const originalMode = process.env[EXAMPLE_MODE_ENV] @@ -66,7 +66,7 @@ describe('resolveExampleLaunch', () => { env: { DSH_HOME: '/tmp/home' }, }) expect(args).not.toContain('--import') - expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') expect(args.slice(-2)).toEqual(['--config', './cordis.yml']) expect(env.TSX_TSCONFIG_PATH).toBeUndefined() expect(env.DSH_HOME).toBe('/tmp/home') @@ -106,6 +106,6 @@ describe('resolveExampleLaunch', () => { it('defaults the mode from the environment', () => { process.env[EXAMPLE_MODE_ENV] = 'lib' const { args } = resolveExampleLaunch({ srcBin: SRC_BIN }) - expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') }) }) diff --git a/packages/todo/README.md b/packages/todo/README.md index bfe5ec7503..c19fab82d3 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [TUI app](../examples/tui-demo) shows a persistent plan, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 6323d86247..2ccdd7699e 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/ui/README.md b/packages/ui/README.md index 3dd26d80a8..bd6b6c9406 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -9,13 +9,12 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 9841d1ce2e..8063964c03 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -2,7 +2,7 @@ Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. -It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui` channel — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 852b1f9f9c..d874d8c154 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-app-boot` -Shared boot glue for the app bins ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. +Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. | Export | Role | |---|---| diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 91ab0d3a2f..e2413fa736 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for the app bins (`dsh-stdio-demo`, `dsh-acp-demo`): load the gitignored + * Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and * drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled. * @module @deepseek-ai/dsh-app-boot diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md deleted file mode 100644 index 07cc6a5b21..0000000000 --- a/packages/ui/stdio/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# @deepseek-ai/dsh-stdio - -The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. - -This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. - -## Config - -| Key | Default | Meaning | -|---|---|---| -| `welcome` | `ready.` | Banner printed before the first prompt | -| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | - -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects. - -```yaml -- id: stdio - name: '@deepseek-ai/dsh-stdio' - config: - welcome: 'agent REPL ready. Give it a coding task.' - sessionId: main -``` - -## Model Experience - -### Readline prompt input - -#### What the model sees - -Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. - -#### Token effect - -Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -### Terminal user-interaction answers - -#### What the model sees - -When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. - -#### Token effect - -Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. -- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. -- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json deleted file mode 100644 index e1bffdf171..0000000000 --- a/packages/ui/stdio/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-stdio", - "description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-agent-loop": { - "optional": true - } - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts deleted file mode 100644 index ac22cf6730..0000000000 --- a/packages/ui/stdio/src/index.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * The stdio app's readline UI: reads lines from stdin into `agent.send()` or - * `steer()`, renders the durable event stream to stdout, buffers startup input - * for one exact agent/session identity, and exits piped input only after - * submitted work reaches idle. - * - * This package is the independently composable stdio front door. It establishes - * the terminal channel and drives an agent created or resumed by app or - * developer code. - * @module @deepseek-ai/dsh-stdio - */ - -import { createInterface } from 'node:readline' -import type { Readable, Writable } from 'node:stream' -import type { Context } from 'cordis' -import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-agent-loop' -import { SessionId } from '@deepseek-ai/dsh-session' -import { - UserInteractionError, - type AskUserQuestionAnswer, - type AskUserQuestionAnswerItem, - type AskUserQuestionItem, - type AskUserQuestionOption, - type AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-interaction' - -export const name = 'ui-stdio' -export const inject = ['agents', 'userInteraction'] - -/** Serializable plugin configuration (cordis-native, schemastery). */ -export interface Config { - /** Banner printed once on start, before the first `> ` prompt. */ - welcome?: string - /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ - sessionId?: string -} - -export const Config: z = z.object({ - welcome: z.string().default('ready.'), - sessionId: z.string().default('main'), -}) - -/** - * Process-I/O seam — the side-effecting handles the plugin would otherwise - * reach for as globals. Defaulted to the real `process` streams in - * {@link apply}; injected by tests so the EOF, render, and disposal branches - * are exercised without hijacking globals. Deliberately NOT part of the - * serializable {@link Config} (streams/functions don't belong in YAML config). - */ -export interface StdioRuntime { - /** Line source (default `process.stdin`). */ - input: Readable - /** Render sink (default `process.stdout`). */ - output: Writable - /** Process-exit hook (default `process.exit`); called once on stdin EOF. */ - exit: (code: number) => void -} - -function isTTYPair(input: Readable, output: Writable): boolean { - return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) -} - -/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ -function renderThrown(value: unknown): string { - try { - return String(value) - } catch { - return '' - } -} - -interface PendingQuestion { - request: AskUserQuestionRequest - questionIndex: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void -} - -type OptionSelection = - | { kind: 'selected'; options: AskUserQuestionOption[] } - | { kind: 'custom' } - | { kind: 'invalid' } - -/** - * The plugin body, parameterized over its I/O runtime. `apply` is the thin - * production wrapper that binds the real `process` streams; tests call this - * directly with fakes. Returns nothing — all registration is via `ctx.on`/ - * `ctx.effect`, so fiber disposal tears every listener and the readline - * interface down. - * @param ctx - the context supplying the `agents` service and the event feeds. - * @param config - the plugin config; defaults are re-applied here for direct - * callers that bypass Loader validation. - * @param runtime - the process-I/O seam (line source, render sink, exit hook). - */ -export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { - // Default here too (not just via schemastery's `.default()`): this helper is - // exported and called directly by tests / programmatic consumers that bypass - // Loader validation, so it must be self-contained rather than trusting the - // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. - const welcome = config.welcome ?? 'ready.' - const sessionId = SessionId(config.sessionId ?? 'main') - const { input, output, exit } = runtime - - // Bind only to the exact identity this app passed to its config-created - // agent. Session ids are opaque: neither a prefix nor registry order can - // identify ownership. The root check rejects a child that somehow preempts - // the configured id; later recreation under the same id supports loop HMR. - const matchesConfiguredIdentity = (agent: Agent): boolean => - agent.id === sessionId && ctx.agents.roots().includes(agent) - let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId) - - // Transcript rendering off the durable `session/event` feed — the assistant - // token stream, turn/step boundaries, tool activity, and todos all come from - // the one canonical stream (no agent/* mirrors). A single listener over the - // append order keeps `inReasoning` transitions deterministic across chunk and - // boundary events. - let inReasoning = false - ctx.on('session/event', (session, event) => { - if (event.type === 'assistant/chunk') { - const { chunk } = event.data - if (chunk.type === 'reasoning-delta') { - // Dim the chain-of-thought so the final answer stands out. - if (!inReasoning) output.write('\x1B[2m') - inReasoning = true - output.write(chunk.text) - } else if (chunk.type === 'text-delta') { - if (inReasoning) output.write('\x1B[0m\n') - inReasoning = false - output.write(chunk.text) - } - } else if (event.type === 'turn/start') { - const label = target?.session === session ? 'main' : session.id - output.write(`\n[${label} turn ${event.data.turn}] `) - } else if (event.type === 'turn/end') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write('\n> ') - } else if (event.type === 'tool/call') { - const { name: toolName, arguments: args } = event.data - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - output.write(`\n [tool call] ${toolName}(${args})`) - } else if (event.type === 'tool/result') { - // A surface replacement changes future model context; it is not another - // execution. Keep the original full-fidelity terminal presentation and - // suppress duplicate output during live delivery or log replay. - if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return - const { content } = event.data - const text = content.filter(block => block.type === 'text').map(block => block.text).join('') - output.write(`\n [tool result] ${text}\n `) - } else if (event.type === 'todo/write') { - if (inReasoning) output.write('\x1B[0m') - inReasoning = false - const glyph = (status: string): string => - status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]' - const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n') - output.write(`\n [todos]\n${lines}\n `) - } - }) - - ctx.effect(() => { - // Piped-input exit, once stdin reaches EOF: - // - If no line ever submitted work (empty stdin, blank-only lines), exit - // immediately — no turn will ever start, so there is nothing to wait - // for. (Gating on an observed 'running' here would hang forever.) - // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Later lines may steer the active turn, and consecutive - // queued turns can share one running interval, so we don't count inputs; - // agent.send() also does NOT synchronously flip status to - // 'running', so requiring an observed 'running' first (`sawRunning`) - // avoids exiting in the gap before the turn starts and dropping work. - let stdinClosed = false - let disposed = false - let submittedWork = false - let sawRunning = false - let exitTimer: ReturnType | undefined - let activeQuestion: PendingQuestion | undefined - const questionQueue: PendingQuestion[] = [] - const queuedInput: string[] = [] - let targetReady = target !== undefined - let hadReadyTarget = targetReady - let failedStartup: { error: unknown } | undefined - - const submit = (agent: Agent, text: string): void => { - submittedWork = true - if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) - } - } - - const disposeCreatedListener = ctx.on('agent/created', (agent) => { - if (!matchesConfiguredIdentity(agent)) return - target = agent - targetReady = false - failedStartup = undefined - }) - const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { - if (agent !== target) return - targetReady = true - hadReadyTarget = true - for (const text of queuedInput.splice(0)) submit(agent, text) - }) - const disposeDisposedListener = ctx.on('agent/disposed', (agent) => { - if (target !== agent) return - target = undefined - targetReady = false - }) - const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - - const maybeExit = (): void => { - if (disposed || !stdinClosed) return - // No work submitted: nothing will ever run, exit straight away. - // Work submitted: wait until a turn has run and the agent is idle. - if (submittedWork) { - if (!sawRunning) return - const agent = target - if (agent && agent.status !== 'idle') return // a turn is still running - } - // Let any final output flush, then exit. The handle is tracked so the - // disposer can cancel it — a dispose within the flush window must not let - // the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g. - // repeated idle signals) coalesce onto the one pending timer. - if (exitTimer !== undefined) { - return // exit already scheduled — coalesce re-entrant calls - } - exitTimer = setTimeout(() => { exit(0) }, 200) - } - - const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => { - if (failedSessionId !== sessionId || targetReady) return - failedStartup = { error } - const dropped = queuedInput.length - queuedInput.length = 0 - submittedWork = sawRunning - if (dropped > 0) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`) - } - maybeExit() - }) - - const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject !== target) return - if (status === 'running') sawRunning = true - if (status === 'idle') maybeExit() - }) - - const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem => - pending.request.questions[pending.questionIndex] as AskUserQuestionItem - - const renderQuestion = (pending: PendingQuestion): void => { - const question = activeQuestionItem(pending) - const options = question.options ?? [] - output.write('\n') - output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`) - options.forEach((option, index) => { - output.write(` ${index + 1}. ${option.label}\n`) - if (option.description) output.write(` ${option.description}\n`) - }) - output.write('> ') - } - - const removeAbortListener = (pending: PendingQuestion): void => { - pending.request.signal?.removeEventListener('abort', pending.onAbort) - } - - const startNextQuestion = (): void => { - if (activeQuestion !== undefined) return - const pending = questionQueue.shift() - if (pending === undefined) return - // The queue never contains an aborted pending ask: the seam rejects an - // already-aborted request synchronously, and queued asks attach their - // abort listener before enqueueing. - activeQuestion = pending - renderQuestion(pending) - } - - const disposeQuestion = (pending: PendingQuestion): void => { - removeAbortListener(pending) - pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED')) - } - - const disposePendingQuestions = (): void => { - if (activeQuestion !== undefined) { - disposeQuestion(activeQuestion) - activeQuestion = undefined - } - for (const pending of questionQueue.splice(0)) { - disposeQuestion(pending) - } - } - - const finishQuestion = (pending: PendingQuestion): void => { - activeQuestion = undefined - removeAbortListener(pending) - pending.resolve({ answers: pending.answers }) - output.write('\n') - startNextQuestion() - } - - const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => { - pending.answers.push(answer) - pending.questionIndex += 1 - if (pending.questionIndex >= pending.request.questions.length) { - finishQuestion(pending) - return - } - renderQuestion(pending) - } - - const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => { - if (text === '') return { kind: 'invalid' } - if (!multiSelect) { - if (!/^\d+$/.test(text)) return { kind: 'custom' } - const selected = options[Number(text) - 1] - return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] } - } - const indices = text.split(/[,\s]+/).filter(Boolean) - if (indices.length === 0) return { kind: 'invalid' } - if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' } - const uniqueIndices = [...new Set(indices)] - const selected = uniqueIndices.map(part => options[Number(part) - 1]) - return selected.some(option => option === undefined) - ? { kind: 'invalid' } - : { kind: 'selected', options: selected as AskUserQuestionOption[] } - } - - const answerQuestion = (line: string): void => { - const pending = activeQuestion as PendingQuestion - const question = activeQuestionItem(pending) - - const text = line.trim() - const options = question.options ?? [] - const selection = options.length > 0 - ? selectedOptions(text, options, question.multiSelect ?? false) - : { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection - if (selection.kind === 'selected') { - answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) }) - return - } - - if (selection.kind === 'custom' && text !== '') { - answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text }) - return - } - - output.write(options.length > 0 - ? 'Please enter one of the option numbers' - + (question.multiSelect ? ' (comma or space separated)' : '') - + ' or a custom answer' - + '.\n> ' - : 'Please enter an answer.\n> ') - } - - const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({ - ask(request) { - if (disposed || stdinClosed) { - return Promise.reject( - new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'), - ) - } - return new Promise((resolve, reject) => { - const pending: PendingQuestion = { - request, - questionIndex: 0, - answers: [], - resolve, - reject, - onAbort: () => { - if (activeQuestion === pending) { - activeQuestion = undefined - disposeQuestion(pending) - startNextQuestion() - return - } - // If it is not active, this listener can only fire while the ask - // remains queued; settled asks remove the listener first. - questionQueue.splice(questionQueue.indexOf(pending), 1) - disposeQuestion(pending) - }, - } - request.signal?.addEventListener('abort', pending.onAbort, { once: true }) - questionQueue.push(pending) - startNextQuestion() - }) - }, - }) - - reader.on('line', (line) => { - if (activeQuestion !== undefined) { - answerQuestion(line) - return - } - const text = line.trim() - if (!text) return - if (failedStartup !== undefined) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`) - return - } - const agent = target - if (agent === undefined || !targetReady) { - // Initial exact-id restoration is asynchronous. Preserve input until - // session-start, the first supported point for queueing agent work. - // After a previously ready target disappears, a line in the HMR gap - // still fails loud unless its exact replacement is already publishing. - if (!hadReadyTarget || agent !== undefined) { - submittedWork = true - queuedInput.push(text) - return - } - ctx.logger.error('ui-stdio: main agent is not running') - return - } - submit(agent, text) - }) - reader.on('close', () => { - // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); - // `disposed` guards teardown so HMR/dispose never exits the process. - stdinClosed = true - if (!disposed) disposePendingQuestions() - maybeExit() - }) - output.write(`${welcome}\n> `) - return () => { - disposed = true - if (exitTimer !== undefined) clearTimeout(exitTimer) - disposePendingQuestions() - disposeUserInteractionProvider() - disposeStatusListener() - disposeCreatedListener() - disposeSessionStartListener() - disposeDisposedListener() - disposeStartupFailedListener() - reader.close() - } - }, 'ui-stdio') -} - -/** - * Open the terminal channel for one exact identity. The chat registers before - * that agent necessarily exists so it can buffer startup input and observe a - * config-start failure instead of leaving piped stdin hanging. - * @param ctx - the context supplying the agent registry and event stream. - * @param config - presentation and target-agent configuration. - * @param runtime - process-I/O seam. - */ -export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { - createStdioChat(ctx, config, runtime) -} - -/** - * Cordis entry point. Binds the real `process` streams and delegates to - * {@link mountStdio}; the indirection keeps the side-effecting handles out - * of the testable core, which is why the unit suite drives `createStdioChat` - * directly. This thin wrapper is exercised end-to-end by the keyless - * Loader-path e2e smoke in `examples/echo-agent` (the real product entry). - */ -/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */ -export function apply(ctx: Context, config: Config): void { - mountStdio(ctx, config, { - input: process.stdin, - output: process.stdout, - exit: code => process.exit(code), - }) -} -/* v8 ignore stop */ diff --git a/packages/ui/stdio/tests/plugin-shape.spec.ts b/packages/ui/stdio/tests/plugin-shape.spec.ts deleted file mode 100644 index 5b2b35f65e..0000000000 --- a/packages/ui/stdio/tests/plugin-shape.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' -import * as stdio from '../src/index.ts' - -/** Real Loader export-path guard for the namespace stdio plugin. */ -describe('dsh-stdio plugin export shape', () => { - it('preserves name, inject, Config, and apply through Loader unwrapping', () => { - expect('default' in stdio).toBe(false) - expect(typeof stdio.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(stdio) as Record - expect(unwrapped).toBe(stdio) - expect(unwrapped.name).toBe('ui-stdio') - expect(unwrapped.inject).toEqual(['agents', 'userInteraction']) - expect(unwrapped.Config).toBeDefined() - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/ui/stdio/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts deleted file mode 100644 index 6a97eab06a..0000000000 --- a/packages/ui/stdio/tests/readline.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { Readable, Writable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import type { StdioRuntime } from '../src/index.ts' - -const createInterface = vi.hoisted(() => vi.fn(() => { - const reader = new EventEmitter() as EventEmitter & { close(): void } - reader.close = vi.fn() - return reader -})) - -vi.mock('node:readline', () => ({ createInterface })) - -function fakeContext(): Context { - return { - on: vi.fn(() => vi.fn()), - effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its root target from the registry at install; this suite only - // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { roots: vi.fn(() => []) }, - userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, - } as unknown as Context -} - -function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { - return { - input: { isTTY: inputIsTTY } as Readable & { isTTY: boolean }, - output: { isTTY: outputIsTTY, write: vi.fn(() => true) } as unknown as Writable & { isTTY: boolean }, - exit: vi.fn(), - } -} - -describe('createStdioChat readline mode', () => { - it('enables terminal editing only when both stdio streams are TTYs', async () => { - const { createStdioChat } = await import('../src/index.ts') - - const tty = fakeRuntime(true, true) - createStdioChat(fakeContext(), {}, tty) - expect(createInterface).toHaveBeenLastCalledWith({ - input: tty.input, - output: tty.output, - terminal: true, - }) - - const piped = fakeRuntime(true, false) - createStdioChat(fakeContext(), {}, piped) - expect(createInterface).toHaveBeenLastCalledWith({ - input: piped.input, - output: piped.output, - terminal: false, - }) - }) -}) diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts deleted file mode 100644 index 478914849c..0000000000 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ /dev/null @@ -1,1044 +0,0 @@ -import { Readable, Writable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' -import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' - -/** - * Unit tests for the stdio UI plugin. They drive the REAL plugin body - * (`createStdioChat`) with an injected {@link StdioRuntime} so every render, - * input, EOF, and disposal branch runs without touching the real `process` - * streams — the I/O seam is what makes the per-file gate reachable. The - * `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent` - * stands in for the loop, since the loop is the genuinely expensive collaborator - * and we only need its `status` + `send`/`steer` surface here. - */ - -/** A controllable stdin: a Readable we push lines into and can end on demand. */ -function makeInput(): Readable & { feed(line: string): void; finish(): void } { - const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void } - stream.feed = (line: string) => stream.push(`${line}\n`) - stream.finish = () => stream.push(null) - return stream -} - -/** A stdout sink that accumulates everything written, for assertions. */ -function makeOutput(): { write: (s: string) => boolean; text: () => string } { - let buf = '' - return { write: (s: string) => { buf += s; return true }, text: () => buf } -} - -function makeRuntime(over: Partial = {}): { - runtime: StdioRuntime - input: ReturnType - out: ReturnType - exit: ReturnType -} { - const input = makeInput() - const out = makeOutput() - const exit = vi.fn() - return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit } -} - -/** A minimal Agent fake exposing the surface the UI touches. */ -function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { - status: AgentStatus - sent: ContentBlock[][] - steered: ContentBlock[][] -} { - const sent: ContentBlock[][] = [] - const steered: ContentBlock[][] = [] - return { - id: id as Agent['id'], - status, - sent, - steered, - // A minimal session stub with the agent's shared durable identity. - session: { id, header: { id } }, - send: (content: ContentBlock[]) => void sent.push(content), - steer: (content: ContentBlock[]) => void steered.push(content), - } as never -} - -/** Register a fake configured agent and cross the supported startup-work boundary. */ -function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { - const dispose = ctx.agents.register(agent) - ctx.emit('agent/session-start', agent, source) - return dispose -} - -/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(id: string): Session { - return { id, header: { id } } as Session -} - -/** An `assistant/chunk` session event carrying one raw stream chunk. */ -function chunkEvent(chunk: StreamChunk): SessionEvent { - return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } -} - -const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } - -function unrenderableFailure(): unknown { - return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } } -} - -async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, input, out, exit } = makeRuntime(runtimeOver) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, config, runtime) - }, { inject: ['agents', 'userInteraction'] })) - return { ctx, fiber, input, out, exit } -} - -/** Drive a fake idle timer past the 200ms flush delay. */ -function flushExit(): Promise { - return new Promise(resolve => setTimeout(resolve, 250)) -} - -describe('mountStdio readiness', () => { - it('opens before the configured agent is created so startup input can queue', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('hi there\n> ') - ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('hi there\n> ') - ctx.agents.register(makeAgent('main')) - expect(out.text()).toBe('hi there\n> ') - await fiber.dispose() - }) - - it('opens immediately when the configured agent already exists', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - ctx.agents.register(makeAgent('main')) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('hi there\n> ') - await fiber.dispose() - }) - - it('opens for the default main identity when no target is configured', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const { runtime, out } = makeRuntime() - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - mountStdio(inner, { welcome: 'ready' }, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(out.text()).toBe('ready\n> ') - ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('ready\n> ') - ctx.agents.register(makeAgent('main')) - expect(out.text()).toBe('ready\n> ') - await fiber.dispose() - }) -}) - -describe('createStdioChat rendering', () => { - it('writes the welcome banner and prompt on start', async () => { - const { out } = await setup() - expect(out.text()).toBe('hi there\n> ') - }) - - it('falls back to the default welcome when called with empty config', async () => { - // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default the welcome itself. - const { out } = await setup({}) - expect(out.text()).toBe('ready.\n> ') - }) - - it('detects readline terminal mode from both stream TTY flags', async () => { - for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - let text = '' - const output = new Writable({ - write(chunk, _encoding, callback) { - text += String(chunk) - callback() - }, - }) as Writable & { isTTY?: boolean } - const { runtime } = makeRuntime({ output }) - ;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY - output.isTTY = outputTTY - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - - expect(text).toContain('hi there') - await fiber.dispose() - } - }) - - it('renders text-delta chunks verbatim', async () => { - const { ctx, out } = await setup() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' })) - expect(out.text()).toContain('hello') - }) - - it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' })) - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' })) - ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' })) - expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer') - }) - - it('ignores stream-chunk types it does not render', async () => { - const { ctx, out } = await setup() - const before = out.text() - ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' })) - expect(out.text()).toBe(before) - }) - - it('renders turn/start and turn/end markers from the session feed', async () => { - const { ctx, out } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - const session = agent.session - ctx.emit('session/event', session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 3] ') - ctx.emit('session/event', session, { - type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } }, - } as SessionEvent) - expect(out.text()).toContain('\n> ') - }) - - it('uses the session id as the label for a non-target session', async () => { - const { ctx, out } = await setup() - // No target exists, so the event's durable identity is the label. - ctx.emit('session/event', makeSession('orphan'), { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[orphan turn 1] ') - }) - - it('uses an agent already registered before the UI installs as its target', async () => { - // The pre-created `main` agent (and any agent surviving an HMR reload of just - // this fiber) fired its `agent/created` before the UI's listener existed, so - // the live listener alone would miss it. Seeding from `ctx.agents.list()` at - // install time preserves the terminal's fixed `[main turn N]` label. - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const agent = makeAgent('main') - // Durable lineage does not imply runtime child ownership: the stdio app - // may explicitly resume a persisted fork as its one configured agent. - ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.agents.register(agent) // registered BEFORE the UI plugin below - const { runtime, out } = makeRuntime() - await ctx.plugin(Object.assign((inner: Context) => { - createStdioChat(inner, CONFIG, runtime) - }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', agent.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 5] ') - }) - - it('buffers input for a lineage-bearing configured agent until its session starts', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) - input.feed('continue') - await new Promise(resolve => setImmediate(resolve)) - - const unrelated = makeAgent('unrelated') - ctx.agents.register(unrelated) - ctx.emit('agent/session-start', unrelated, 'startup') - const resumed = makeAgent('resumed') - ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.agents.register(resumed) - await new Promise(resolve => setImmediate(resolve)) - expect(resumed.sent).toEqual([]) - - ctx.emit('agent/session-start', resumed, 'resume') - await new Promise(resolve => setImmediate(resolve)) - - expect(unrelated.sent).toEqual([]) - expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) - }) - - it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' })) - ctx.emit('session/event', session, { - type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } }, - } as SessionEvent) - expect(out.text()).toContain('\x1B[2mmid\x1B[0m') - }) - - it('drops the target object on agent/disposed', async () => { - const { ctx, out } = await setup() - const agent = makeAgent('main') - const dispose = ctx.agents.register(agent) - dispose() - // After disposal the event belongs to a non-target session, so its durable - // identity is rendered directly. - ctx.emit('session/event', agent.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 1] ') - }) - - it('keeps the target when a different agent is disposed', async () => { - const { ctx, out } = await setup() - const target = makeAgent('main') - ctx.agents.register(target) - ctx.emit('agent/disposed', makeAgent('other')) - ctx.emit('session/event', target.session, { - type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, - } as SessionEvent) - expect(out.text()).toContain('[main turn 1] ') - }) - - it('retargets only the exact identity after loop HMR recreation', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) - const oldRoot = makeAgent('main-session-fixed') - const prefixCollision = makeAgent('main-session-unrelated') - const disposeOld = ctx.agents.register(oldRoot) - ctx.agents.register(prefixCollision) - disposeOld() - const replacement = makeAgent('main-session-fixed') - ctx.agents.register(replacement) - input.feed('after hmr') - await new Promise(resolve => setImmediate(resolve)) - expect(replacement.sent).toEqual([]) - ctx.emit('agent/session-start', replacement, 'resume') - await new Promise(resolve => setImmediate(resolve)) - - expect(prefixCollision.sent).toEqual([]) - expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) - }) - - it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { - const { ctx, input } = await setup() - const unrelated = makeAgent('unrelated') - ctx.agents.register(unrelated) - const configured = makeAgent('main') - const disposeConfigured = registerReady(ctx, configured) - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - - disposeConfigured() - input.feed('must not leak') - await new Promise(resolve => setImmediate(resolve)) - - expect(unrelated.sent).toEqual([]) - expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running') - }) - - it('renders tool/call and tool/result session events', async () => { - const { ctx, out } = await setup() - const session = {} as Session - const callEvent = { - type: 'tool/call', seq: 1, time: 0, - data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }, - } as SessionEvent - ctx.emit('session/event', session, callEvent) - expect(out.text()).toContain('[tool call] bash({"command":"ls"})') - - const resultEvent = { - type: 'tool/result', seq: 2, time: 0, - data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false }, - } as SessionEvent - ctx.emit('session/event', session, resultEvent) - expect(out.text()).toContain('[tool result] file.txt') - }) - - it('renders one full-fidelity result whether the event feed is live or replayed', async () => { - const { ctx, out } = await setup() - const session = makeSession('main') - const original = { - type: 'tool/result', - seq: 2, - time: 0, - data: { - turn: 1, - step: 1, - callId: 'c1', - content: [{ type: 'text', text: 'full terminal output' }], - isError: false, - meta: { terminal: { output: 'full terminal output' } }, - }, - surfaceOp: 'append', - } as SessionEvent - const replacement = { - ...original, - seq: 3, - data: { - ...original.data, - content: [{ type: 'text', text: '[... tool result middle pruned ...]' }], - }, - surfaceOp: { op: 'replace', start: 2, end: 2 }, - sourceEventSeqs: [2], - } as SessionEvent - - // Stdio consumes the same session/event shape whether a host forwards a - // live append or replays a stored log through the rendering feed. - for (const event of [original, replacement]) ctx.emit('session/event', session, event) - - expect(out.text().match(/\[tool result\]/g)).toHaveLength(1) - expect(out.text()).toContain('full terminal output') - expect(out.text()).not.toContain('tool result middle pruned') - }) - - it('renders a todo/write session event as a glyphed checklist', async () => { - const { ctx, out } = await setup() - const session = {} as Session - ctx.emit('session/event', session, { - type: 'todo/write', seq: 1, time: 0, - data: { todos: [ - { content: 'read the code', status: 'completed' }, - { content: 'write the fix', status: 'in_progress' }, - { content: 'run the tests', status: 'pending' }, - ] }, - } as SessionEvent) - const text = out.text() - expect(text).toContain('[todos]') - expect(text).toContain('[x] read the code') - expect(text).toContain('[~] write the fix') - expect(text).toContain('[ ] run the tests') - }) - - it('resets dim styling when a todo/write interrupts reasoning', async () => { - const { ctx, out } = await setup() - ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', {} as Session, { - type: 'todo/write', seq: 1, time: 0, - data: { todos: [{ content: 'a task', status: 'pending' }] }, - } as SessionEvent) - expect(out.text()).toContain('\x1B[2mr\x1B[0m') - }) - - it('resets dim styling when a tool/call interrupts reasoning', async () => { - const { ctx, out } = await setup() - const session = {} as Session - ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' })) - ctx.emit('session/event', session, { - type: 'tool/call', seq: 1, time: 0, - data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, - } as SessionEvent) - expect(out.text()).toContain('\x1B[2mr\x1B[0m') - }) - - it('ignores session events it does not render', async () => { - const { ctx, out } = await setup() - const before = out.text() - ctx.emit('session/event', {} as Session, { - type: 'user/message', seq: 1, time: 0, - data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, - } as SessionEvent) - expect(out.text()).toBe(before) - }) -}) - -describe('createStdioChat input', () => { - it('answers a pending user question instead of sending the line to the agent', async () => { - const { ctx, input, out } = await setup() - const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) - - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'confirm', - header: 'Confirm', - question: 'Proceed with the edit?', - options: [{ label: 'Yes', description: 'Apply the edit now.' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('Use a smaller change') - - await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] }) - expect(agent.sent).toEqual([]) - expect(out.text()).toContain('[Confirm] Proceed with the edit?') - expect(out.text()).toContain('1. Yes') - expect(out.text()).toContain('Apply the edit now.') - }) - - it('answers a pending user question by numeric option selection', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [ - { label: 'Safe' }, - { label: 'Fast' }, - ], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Fast'] }], - }) - }) - - it('renders options in input order and selects by displayed number', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'topic', - question: 'Which topic?', - options: [ - { label: 'Hobbies' }, - { label: 'Work', description: 'Questions about current projects.' }, - { label: 'Casual', description: 'Easy conversation.' }, - ], - }], - }) - await new Promise(r => setImmediate(r)) - - expect(out.text()).toContain([ - 'Which topic?', - ' 1. Hobbies', - ' 2. Work', - ' Questions about current projects.', - ' 3. Casual', - ' Easy conversation.', - ].join('\n')) - input.feed('3') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'topic', selected: ['Casual'] }], - }) - }) - - it('answers a multi-select question with multiple numeric selections', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'targets', - question: 'What should I update?', - options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('1 1, 3') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'targets', selected: ['Tests', 'Code'] }], - }) - }) - - it('accepts non-numeric multi-select input as a custom answer', async () => { - const { ctx, input } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'targets', - question: 'What should I update?', - options: [{ label: 'Tests' }, { label: 'Docs' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('the release notes') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'targets', selected: [], custom: 'the release notes' }], - }) - }) - - it('asks every question in a batch and returns answers by id', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [ - { id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] }, - { id: 'note', question: 'Any note?' }, - ], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('\nAny note?\n') - input.feed('ship today') - - await expect(answer).resolves.toEqual({ - answers: [ - { id: 'language', selected: ['TypeScript'] }, - { id: 'note', selected: [], custom: 'ship today' }, - ], - }) - }) - - it('re-prompts when option input is invalid', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when single-select option input is out of range', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('2') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when multi-select input contains no option numbers', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - multiSelect: true, - }], - }) - await new Promise(r => setImmediate(r)) - input.feed(',') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when an option question receives an empty answer', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ - questions: [{ - id: 'mode', - question: 'Which mode?', - options: [{ label: 'Safe' }], - }], - }) - await new Promise(r => setImmediate(r)) - input.feed('') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.') - input.feed('1') - - await expect(answer).resolves.toEqual({ - answers: [{ id: 'mode', selected: ['Safe'] }], - }) - }) - - it('re-prompts when a question receives an empty answer', async () => { - const { ctx, input, out } = await setup() - const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] }) - await new Promise(r => setImmediate(r)) - input.feed('') - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('Please enter an answer.') - input.feed('Use defaults') - - await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] }) - }) - - it('rejects an active question when its signal aborts', async () => { - const { ctx } = await setup() - const controller = new AbortController() - const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await rejected - }) - - it('continues to the next queued question when the active question aborts', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal }) - const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] }) - await new Promise(r => setImmediate(r)) - - controller.abort() - await firstRejected - await new Promise(r => setImmediate(r)) - expect(out.text()).toContain('\nSecond?\n') - input.feed('second answer') - - await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] }) - }) - - it('skips a queued question whose signal aborted before it became active', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await expect(Promise.race([ - second.then( - () => 'resolved', - (error: unknown) => (error as { code?: string }).code, - ), - new Promise((resolve) => { setImmediate(() => { resolve('pending') }) }), - ])).resolves.toBe('ASK_ABORTED') - expect(out.text()).not.toContain('\nSecond?\n') - input.feed('first answer') - await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) - }) - - it('removes an aborted queued question without promoting later queued work early', async () => { - const { ctx, input, out } = await setup() - const controller = new AbortController() - const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) - const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) - const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] }) - await new Promise(r => setImmediate(r)) - - controller.abort() - - await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - expect(out.text()).toContain('\nFirst?\n') - expect(out.text()).not.toContain('\nSecond?\n') - expect(out.text()).not.toContain('\nThird?\n') - input.feed('first answer') - await new Promise(r => setImmediate(r)) - - expect(out.text()).toContain('\nThird?\n') - input.feed('third answer') - - await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) - await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] }) - }) - - it('rejects active and queued questions when the UI is disposed', async () => { - const { ctx, fiber } = await setup() - const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) - const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) - const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - await fiber.dispose() - - await activeRejected - await queuedRejected - }) - - it('rejects active and queued questions when stdin closes before the user answers', async () => { - const { ctx, input, exit } = await setup() - const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) - const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) - const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - await new Promise(r => setImmediate(r)) - - input.finish() - await new Promise(r => setImmediate(r)) - - await activeRejected - await queuedRejected - expect(exit).not.toHaveBeenCalled() - }) - - it('rejects new questions immediately after stdin has closed', async () => { - const { ctx, input, out } = await setup() - input.finish() - await new Promise(r => setImmediate(r)) - const before = out.text() - - const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] }) - - await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) - expect(out.text()).toBe(before) - }) - - it('sends a typed line to an idle agent', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('do a thing') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]]) - expect(agent.steered).toEqual([]) - }) - - it('steers a typed line into a running agent', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main', 'running') - registerReady(ctx, agent) - input.feed('steer me') - await new Promise(r => setImmediate(r)) - expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]]) - expect(agent.sent).toEqual([]) - }) - - it('ignores blank lines', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - input.feed(' ') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - }) - - it('buffers a line until the initial target session starts', async () => { - const { ctx, input } = await setup() - const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - input.feed('nobody home') - await new Promise(r => setImmediate(r)) - expect(spy).not.toHaveBeenCalled() - - const agent = makeAgent('main') - ctx.agents.register(agent) - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) - }) - - it('drops later input after the configured startup fails', async () => { - const { ctx, input } = await setup() - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - const failure = unrenderableFailure() - ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) - - input.feed('cannot run') - await new Promise(r => setImmediate(r)) - - expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', - ) - }) - - it('ignores a stale config-start failure after the exact target is ready', async () => { - const { ctx, input } = await setup() - const agent = makeAgent('main') - registerReady(ctx, agent) - ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale')) - - input.feed('still live') - await new Promise(r => setImmediate(r)) - - expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]]) - }) - - it('drives the exact app-configured resumed session', async () => { - const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) - const agent = makeAgent('worker') - registerReady(ctx, agent, 'resume') - input.feed('hi') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toHaveLength(1) - }) - -}) - -describe('createStdioChat EOF exit', () => { - it('exits immediately on EOF when no work was submitted', async () => { - const { input, exit } = await setup() - input.finish() - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('waits for the agent to settle idle after running before exiting', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - input.finish() - await new Promise(r => setImmediate(r)) - // Work submitted but no 'running' observed yet — must NOT exit. - expect(exit).not.toHaveBeenCalled() - // The turn starts, then settles. - ctx.emit('agent/status', agent, 'running') - ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('keeps piped EOF pending until buffered startup input runs', async () => { - const { ctx, input, exit } = await setup() - input.feed('work') - input.finish() - await flushExit() - expect(exit).not.toHaveBeenCalled() - - const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - ctx.emit('agent/session-start', agent, 'startup') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) - ctx.emit('agent/status', agent, 'running') - ;(agent as { status: AgentStatus }).status = 'idle' - ctx.emit('agent/status', agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledWith(0) - }) - - it('drains buffered piped input and exits when configured startup fails', async () => { - const { ctx, input, exit } = await setup() - const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - input.feed('work') - input.finish() - await new Promise(r => setImmediate(r)) - ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated')) - await flushExit() - expect(exit).not.toHaveBeenCalled() - - ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure()) - await flushExit() - - expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', - ) - expect(exit).toHaveBeenCalledWith(0) - }) - - it('schedules the exit only once when idle fires repeatedly', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'running') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') // sawRunning = true - input.finish() - await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed - ;(agent as { status: AgentStatus }).status = 'idle' - // Two idle signals while stdin is already closed: the first arms the timer, - // the second must hit the already-scheduled guard, not arm a second. - ctx.emit('agent/status', agent, 'idle') - ctx.emit('agent/status', agent, 'idle') - await flushExit() - expect(exit).toHaveBeenCalledTimes(1) - }) - - it('does not exit on an idle transition for a different agent', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - input.finish() - const other = makeAgent('other') - ctx.emit('agent/status', other, 'running') - ctx.emit('agent/status', other, 'idle') - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('does not exit while a turn is still running at EOF', async () => { - const { ctx, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - ctx.emit('agent/status', agent, 'running') - ;(agent as { status: AgentStatus }).status = 'running' - input.finish() - // sawRunning is true, but the agent is still running — the idle gate holds. - ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running' - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) -}) - -describe('createStdioChat disposal (HMR safety)', () => { - it('never exits the process when EOF arrives after fiber dispose', async () => { - const { fiber, input, exit } = await setup() - await fiber.dispose() - // A late EOF after disposal (reader.close() also fires 'close') must not exit. - input.finish() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('cancels a scheduled exit if disposed within the flush window', async () => { - const { fiber, input, exit } = await setup() - // EOF with no work submitted schedules the 200ms flush-then-exit timer. - input.finish() - await new Promise(r => setImmediate(r)) - expect(exit).not.toHaveBeenCalled() // not yet — still inside the window - // Dispose BEFORE the timer fires: the tracked handle must be cleared. - await fiber.dispose() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) - - it('stops handling input after dispose', async () => { - const { ctx, fiber, input } = await setup() - const agent = makeAgent('main') - ctx.agents.register(agent) - await fiber.dispose() - // The readline interface is closed on dispose; a late line reaches no handler. - input.feed('too late') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toEqual([]) - }) - - it('removes the agent/status listener on dispose', async () => { - const { ctx, fiber, input, exit } = await setup() - const agent = makeAgent('main', 'idle') - registerReady(ctx, agent) - input.feed('work') - await new Promise(r => setImmediate(r)) - await fiber.dispose() - // After dispose, status transitions must neither throw nor schedule an exit - // (the listener and the EOF-exit path are both torn down). - expect(() => { - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') - }).not.toThrow() - await flushExit() - expect(exit).not.toHaveBeenCalled() - }) -}) diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json deleted file mode 100644 index e0c578ed32..0000000000 --- a/packages/ui/stdio/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/agent-loop" - }, - { - "path": "../../core/session" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../user-interaction" - } - ] -} diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 6d2c7858e4..f44644c430 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tui -The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead. +The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the headless [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead. The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. @@ -77,4 +77,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. -- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback. +- **Non-TTY operation is intentionally unsupported** — automation must use the headless app rather than expecting an internal fallback. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1c3fc1315c..46e68566d7 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1342,10 +1342,10 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi /** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */ /* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat, - and the repl-agent PTY smoke covers the real entry */ + and the tui-agent PTY smoke covers the real entry */ export function apply(ctx: Context, config: Config): void { if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-stdio for pipes') + throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-cli-demo for non-interactive runs') } mountTui(ctx, config, { terminal: new ProcessTerminal(), diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index 2ddf261c3a..c026ff0395 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; the interactive `dsh-tui` and structured `dsh-acp` front doors provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. ## Model Experience diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7712cf7732..f1c3c84521 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:* version: link:../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-app-boot': + specifier: workspace:* + version: link:../packages/ui/app-boot '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -170,9 +173,6 @@ importers: '@deepseek-ai/dsh-spill-policy': specifier: workspace:* version: link:../packages/spill/spill-policy - '@deepseek-ai/dsh-stdio-demo': - specifier: workspace:* - version: link:../packages/examples/stdio-demo '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../packages/subagent/subagent @@ -212,6 +212,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../packages/core/tools + '@deepseek-ai/dsh-tui-demo': + specifier: workspace:* + version: link:../packages/examples/tui-demo '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../packages/ui/user-approval @@ -838,7 +841,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/examples/stdio-demo: + packages/examples/tui-demo: devDependencies: '@cordisjs/plugin-include': specifier: workspace:^ @@ -846,9 +849,6 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@cordisjs/plugin-logger-console': - specifier: workspace:^ - version: link:../../../vendor/logger-console '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -870,9 +870,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-stdio': - specifier: workspace:^ - version: link:../../ui/stdio '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2147,34 +2144,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/ui/stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/ui/tool-ask-user: devDependencies: '@deepseek-ai/dsh-agent': @@ -4767,10 +4736,6 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} - '@xmldom/xmldom@0.9.10': - resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} - engines: {node: '>=14.6'} - '@xterm/headless@5.5.0': resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} @@ -8953,8 +8918,6 @@ snapshots: transitivePeerDependencies: - typescript - '@xmldom/xmldom@0.9.10': {} - '@xterm/headless@5.5.0': {} accepts@2.0.0: diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 43bff2d4ba..273e6e1b38 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,23 +1,20 @@ /** - * Boot the REPL, TUI, or ACP Code Mode overlay, defaulting to REPL. Each overlay + * Boot the TUI or ACP Code Mode overlay, defaulting to TUI. Each overlay * includes its base example, selects Code Mode, and adds the worker runtime. * All require a DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' -// Each UI's node invocation, verbatim what its base demo script runs plus -// the overlay config (the stdio bin keeps --expose-internals for the cordis -// Loader's HMR path). +// Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/repl-agent/code-mode.cordis.yml']], - ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) -const ui = process.argv[2] ?? 'repl' +const ui = process.argv[2] ?? 'tui' const args = UIS.get(ui) if (!args || process.argv.length > 3) { - console.error('usage: pnpm run demo:code-mode [repl|tui|acp]') + console.error('usage: pnpm run demo:code-mode [tui|acp]') process.exit(2) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c05aa1e54f..e2f70bbd8c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -148,8 +148,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'user-interaction', title: 'Human question/answer seam', mode: 'seam', - implementations: ['stdio-demo', 'acp'], - consumers: ['tool-ask-user', 'stdio-demo', 'acp'], + implementations: ['tui', 'acp'], + consumers: ['tool-ask-user', 'tui', 'acp'], note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { @@ -166,7 +166,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo', 'invariants'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { @@ -441,15 +441,7 @@ const APP_EXAMPLES = [ title: 'Echo Agent App Composition', label: 'examples/echo-agent', config: 'examples/echo-agent/cordis.yml', - summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.', - }, - { - id: 'repl', - rel: 'examples/repl-agent/composition.md', - title: 'REPL Agent App Composition', - label: 'examples/repl-agent', - config: 'examples/repl-agent/cordis.yml', - summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.', + summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the headless one-shot app package.', }, { id: 'tui', @@ -457,7 +449,7 @@ const APP_EXAMPLES = [ title: 'TUI Agent App Composition', label: 'examples/tui-agent', config: 'examples/tui-agent/cordis.yml', - summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.', + summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { id: 'headless', @@ -487,18 +479,13 @@ const APP_EXAMPLES = [ type AppExample = typeof APP_EXAMPLES[number] -function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void { +function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void { const agentCore = nodeId('bundle', 'agent_core') const jsonl = nodeId('bundle', 'jsonl') lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) - if (pluginName === '@deepseek-ai/dsh-stdio-demo') { - const frontDoor = exampleId === 'tui' - ? '@deepseek-ai/dsh-tui
pre-created main agent' - : exampleId === 'repl' - ? '@deepseek-ai/dsh-stdio
pre-created main agent' - : 'dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent' - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`) + if (pluginName === '@deepseek-ai/dsh-tui-demo') { + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui
pre-created main agent"]`) } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
fresh top-level agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { @@ -527,8 +514,8 @@ function renderAppComposition(example: AppExample): string { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) - if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { - renderAppExpansion(lines, pluginNode, plugin.name, example.id) + if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { + renderAppExpansion(lines, pluginNode, plugin.name) } } lines.push( @@ -1027,7 +1014,6 @@ function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', 'examples/echo-agent/composition.md': 'echo-agent app composition', - 'examples/repl-agent/composition.md': 'repl-agent app composition', 'examples/headless-agent/composition.md': 'headless-agent app composition', 'examples/tui-agent/composition.md': 'tui-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', @@ -1040,7 +1026,6 @@ function renderIndex(docs: GraphDoc[]): string { const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', 'examples/echo-agent/composition.md': 'hybrid generated', - 'examples/repl-agent/composition.md': 'hybrid generated', 'examples/headless-agent/composition.md': 'hybrid generated', 'examples/tui-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 287133454a..b831004a3b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -247,7 +247,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-tasks', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a91af85f2e..1e2924bd33 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -358,18 +358,17 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate { return { id: 'demo-smoke', label: 'demo smoke', - displayCommand: 'pnpm run demo:echo', - ...pnpmInvocation(['run', 'demo:echo']), - input: 'echo ci smoke\n', + displayCommand: 'pnpm run demo:echo --output-format stream-json -- "echo ci smoke"', + ...pnpmInvocation(['run', 'demo:echo', '--output-format', 'stream-json', '--', 'echo ci smoke']), ...dependencyOptions, verify: async (result) => { const output = result.stdout + result.stderr const sessionsRoot = join(root, '.sessions') try { - if (!output.includes('[tool call] echo({"text":"ci smoke"})')) { + if (!output.includes('"type":"tool/call"') || !output.includes('"name":"echo"')) { throw new Error('demo smoke did not show the echo tool call.') } - if (!output.includes('[tool result] ECHO: CI SMOKE')) { + if (!output.includes('ECHO: CI SMOKE')) { throw new Error('demo smoke did not show the echo tool result.') } const buckets = await readdir(sessionsRoot, { withFileTypes: true }) @@ -396,7 +395,8 @@ function builtBinSmokeGate(): Gate { 'run', '--config', 'vitest.e2e.config.ts', - 'packages/examples/stdio-demo/tests/built-bin.e2e.ts', + 'examples/echo-agent/tests/echo.e2e.ts', + 'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', @@ -408,6 +408,7 @@ function builtBinSmokeGate(): Gate { ], { label: 'built-bin smoke', needs: ['build'], + env: { DSH_EXAMPLE_MODE: 'lib' }, }) } diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md index 5b984f3b86..d05cae182a 100644 --- a/skills/create-dsh-sdk-project/SKILL.md +++ b/skills/create-dsh-sdk-project/SKILL.md @@ -30,7 +30,7 @@ block. "provider": "deepseek", "apiKey": "", "model": "deepseek-v4-flash", - "interface": "stdio", + "interface": "tui", "pm": "npm", "install": false, "features": [ diff --git a/tsconfig.build.json b/tsconfig.build.json index 9e87410441..189f23fdc4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -76,8 +76,7 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/ui/stdio" }, - { "path": "./packages/examples/stdio-demo" }, + { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, diff --git a/tsconfig.json b/tsconfig.json index 1fbd7e362c..29474f33eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -89,8 +89,7 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/ui/stdio" }, - { "path": "./packages/examples/stdio-demo" }, + { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, From df7be480e1c67d6ae65cdeecda34660da46fa5a9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:28:49 +0800 Subject: [PATCH 113/132] test(snapshot): cover invalid delayed prompt ordering --- packages/support/acp-snapshot/tests/harness.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 748b9e1606..1f69388968 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -458,6 +458,7 @@ describe('runScenario', () => { it.each([ [{ op: 'prompt', text: 'x' }, /prompt before newSession/], + [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], [{ op: 'cancel' }, /cancel before newSession/], From 60926e32577d1fc0fe9357cdde04f2fef03379d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:46:17 +0800 Subject: [PATCH 114/132] test(ralph): await child readiness event --- .../workflow/tool-ralph/tests/integration.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 836f023b41..e2c704eaa3 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -234,13 +234,18 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) - it('cancels the real worker and fresh child to quiescence', async () => { + it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => { const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 }) const children: Agent[] = [] const outcomes: string[] = [] + let resolveChildStarted!: (child: Agent) => void + const childStarted = new Promise((resolve) => { resolveChildStarted = resolve }) ctx.on('workflow/agent-start', (_run, child) => { const agent = ctx.agents.get(child.childId) - if (agent !== undefined) children.push(agent) + if (agent !== undefined) { + children.push(agent) + resolveChildStarted(agent) + } }) ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) }) const controller = new AbortController() @@ -251,7 +256,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { agent: parent, signal: controller.signal, }) - await vi.waitFor(() => { expect(children).toHaveLength(1) }) + await childStarted controller.abort() const result = await pending From 109df5210e2b8107ea517fb4ce9a328fe0c5ff2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:13:39 +0800 Subject: [PATCH 115/132] docs(goal): align current-base contracts --- .../2026-07-19-persisted-same-session-goal-domain.i18n.yaml | 4 ++-- docs/architecture.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml index b960f69e71..5049947f18 100644 --- a/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-persisted-same-session-goal-domain.md: 75355aa8d94789e0cc227d393c50139708a73f6a -2026-07-19-persisted-same-session-goal-domain.zh.md: fd7e31f4b6a5acec1d2b44fb3088e301b36abd5c +2026-07-19-persisted-same-session-goal-domain.md: b0149016ab1b2a21c6d21798bf8b2117472a0f6c +2026-07-19-persisted-same-session-goal-domain.zh.md: 33f44136da3f0045d9f4baad5154797a6e746bcc diff --git a/docs/architecture.md b/docs/architecture.md index 9a83346924..3ada0d1ba3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, inclu ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations. +Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompts, tools, providers, adapters, and listeners. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -110,7 +110,7 @@ forever: Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through turn close and flush, and discards later steering while preserving queued prompts. +Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles after results. Steering drains; before signal closure, `agent/post-step` observes durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` runs after continuation and steering folding, remains authoritative through close/flush, and discards later steering while preserving queued prompts. Optional pruning precedes summaries; retry requires durable surface progress; cancellation wins ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). @@ -176,7 +176,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Manage a same-session objective | call `ctx.goals`; drive continuation through `Agent` and `agent/*` seams | +| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | | Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a tool, prompt section, or listener to ONE agent | register it through that agent's `agent.ctx` (see Agent Scope) | From 44ad2ef073b99c186cf2026f7ec7a526cb110b40 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:16:41 +0800 Subject: [PATCH 116/132] Remove the Echo agent --- ...2026-06-20-extract-example-app-packages.md | 7 +-- .../2026-06-20-package-hierarchy.md | 2 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-06-16-pnpm-over-yarn.md | 2 +- .../2026-07-03-documentation-graph-atlas.md | 3 +- .../process/2026-07-06-node-engine-floor.md | 2 +- ...rop-unconsumed-llm-adapter-change-event.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- .../2026-07-20-remove-stdio-agent.md | 44 -------------- .../2026-07-20-remove-stdio-agent.zh.md | 44 -------------- ...20-remove-stdio-and-echo-agents.i18n.yaml} | 4 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 45 ++++++++++++++ ...6-07-20-remove-stdio-and-echo-agents.zh.md | 45 ++++++++++++++ ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 2 +- ...6-07-18-tui-terminal-state-snapshots.zh.md | 2 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 4 +- AGENTS.md | 8 +-- README.i18n.yaml | 4 +- README.md | 10 ++-- README.zh.md | 10 ++-- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 6 +- docs/development.zh.md | 6 +- docs/graph-atlas.md | 1 - docs/testing.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 12 ++-- docs/user/develop/basic/index.zh.md | 12 ++-- .../develop/practice/llm-adapter.i18n.yaml | 4 +- docs/user/develop/practice/llm-adapter.md | 3 +- docs/user/develop/practice/llm-adapter.zh.md | 3 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 1 - docs/user/guide/config.zh.md | 1 - docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 24 +++++--- docs/user/guide/quickstart.zh.md | 24 +++++--- examples/AGENTS.md | 2 - examples/README.md | 13 +--- examples/echo-agent/README.md | 25 -------- examples/echo-agent/composition.md | 40 ------------- examples/echo-agent/cordis.yml | 26 -------- examples/echo-agent/package.json | 7 --- examples/echo-agent/src/echo-tool.ts | 19 ------ examples/echo-agent/src/mock-llm.ts | 59 ------------------- examples/echo-agent/tests/echo.e2e.ts | 37 ------------ .../tests/fixtures/time-context-driver.ts} | 2 +- .../tests/fixtures/time-context-mock-llm.ts | 22 +++++++ .../tests/fixtures/time-context.cordis.yml} | 8 +-- knip.json | 3 +- package.json | 1 - .../time-context/tests/time-context.e2e.ts | 4 +- scripts/gen-doc-graphs.ts | 10 ---- scripts/run-gates.ts | 48 +-------------- 64 files changed, 230 insertions(+), 483 deletions(-) delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md rename .agents/notes/implemented/simplification/{2026-07-20-remove-stdio-agent.i18n.yaml => 2026-07-20-remove-stdio-and-echo-agents.i18n.yaml} (62%) create mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md create mode 100644 .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md delete mode 100644 examples/echo-agent/README.md delete mode 100644 examples/echo-agent/composition.md delete mode 100644 examples/echo-agent/cordis.yml delete mode 100644 examples/echo-agent/package.json delete mode 100644 examples/echo-agent/src/echo-tool.ts delete mode 100644 examples/echo-agent/src/mock-llm.ts delete mode 100644 examples/echo-agent/tests/echo.e2e.ts rename examples/{echo-agent/tests/fixtures/context/time-context/driver.ts => headless-agent/tests/fixtures/time-context-driver.ts} (89%) create mode 100644 examples/headless-agent/tests/fixtures/time-context-mock-llm.ts rename examples/{echo-agent/tests/fixtures/context/time-context/cordis.yml => headless-agent/tests/fixtures/time-context.cordis.yml} (74%) diff --git a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md index f9e4217466..05d72e4e49 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -16,7 +16,6 @@ Each example is now **mostly an invocation of an app package**, splitting the wi - **`@deepseek-ai/dsh-tui-demo`**, **`@deepseek-ai/dsh-cli-demo`**, and **`@deepseek-ai/dsh-acp-demo`** bake in their process roles. TUI includes the full-screen UI and a pre-created `main`; Headless includes the one-shot driver and a pre-created `main`; ACP includes the bridge and no pre-created agent. All three include JSONL persistence and omit stdout loggers. - **`start.ts` is gone.** Each app package exposes a bin; the `demo:*` scripts invoke it. Loader boot, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); the thin self-executing entries are driven by keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends, optional product tools, and one app entry carrying the app config. TUI and Headless route model/session choices onto a pre-created agent; ACP routes the initial provider/model onto its bridge. -- **echo-agent loads `dsh-cli-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` at the leaf. `mock-llm.ts` and `echo-tool.ts` stay as example-local teaching plugins. - **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`. `bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app. @@ -39,13 +38,13 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Verification - Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone. -- `demo:echo`, `demo:tui`, `demo:headless`, and `demo:acp` invoke the app-package bins. +- `demo:tui`, `demo:headless`, and `demo:acp` invoke the app-package bins. - Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). - The ACP replay transcript remains unchanged because the plugin set and load order did not change. ## Consequences -- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight. +- **The bare-plugin-tree pedagogy.** The spine lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight. - **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan. ## Related @@ -53,4 +52,4 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a - Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted. - Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle. - Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors). -- The later [remove-stdio-agent decision](../simplification/2026-07-20-remove-stdio-agent.md) owns the final TUI/Headless split and removal of the line-oriented app. +- The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns the final TUI/Headless split and removes the line-oriented and mock-only leaves. diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 800e49cf3c..85c62b7e17 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -2,7 +2,7 @@ Status: implemented -The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [remove-stdio-agent](../simplification/2026-07-20-remove-stdio-agent.md) decision subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here. +The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here. ## Problem diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index f1fc99ade2..bc77edbd0a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: c7bd01011121f04683afde1d05b046970e497a71 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5de443f367725a116e70d368657562b6732904e7 +2026-07-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index c7bd010111..8fbc5dddc0 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -6,7 +6,7 @@ English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) ## Problem -At the time this front door was introduced, the line-oriented agent handled pipes and ordinary terminals, but a full-screen coding interface had to own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin would have coupled a stream-oriented path to a TTY-only lifecycle. The later [remove-stdio-agent decision](../simplification/2026-07-20-remove-stdio-agent.md) removes that redundant line agent; this Note continues to own the TUI design. +At the time this front door was introduced, the line-oriented agent handled pipes and ordinary terminals, but a full-screen coding interface had to own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin would have coupled a stream-oriented path to a TTY-only lifecycle. The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) removes that line agent; this Note continues to own the TUI design. The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 5de443f367..6ddc3523b7 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -在本入口引入时,面向行的 agent 负责 pipe 与普通终端,但全屏 coding 界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使面向 stream 的路径依赖仅适用于 TTY 的生命周期。后续的[移除 stdio agent 决策](../simplification/2026-07-20-remove-stdio-agent.md)移除了这一重复的面向行 agent;本 Note 继续负责 TUI 设计。 +在本入口引入时,面向行的 agent 负责 pipe 与普通终端,但全屏 coding 界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使面向 stream 的路径依赖仅适用于 TTY 的生命周期。后续的[移除重复 agent 决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)移除了这个面向行 agent;本 Note 继续负责 TUI 设计。 交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 1a1cfe5b54..84c3da7b95 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -15,7 +15,7 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus a demo smoke test driving the echo-agent end to end. +- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index f4a5d43f96..42eb4228b6 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -38,4 +38,4 @@ Performance (measured at migration time on the dev NFS filesystem; single-digit- On a fast local disk pnpm's content-addressed store typically wins on cold/warm installs and, especially, on **disk footprint** across multiple checkouts (one global store hardlinked into every `node_modules` vs Yarn copying ~279 MB per worktree — some devs regularly keep ~10 or more worktrees for this repo). That dedup advantage did **not** show in the migration-time numbers above because the test store and `node_modules` sat on different filesystems, defeating hardlinks; on a single-filesystem dev box or CI cache it applies. The honest summary: install speed on our NFS dev filesystem is a wash within noise; the move is justified by ecosystem alignment, phantom-dependency safety, and cross-checkout disk dedup — not by a raw install-time win. -All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, knip, publint, echo-agent demo smoke) pass unchanged on pnpm, which is the correctness proof that the linker swap introduced no phantom-dependency breakage. +All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, knip, publint, and built application smokes) pass on pnpm, which is the correctness proof that the linker swap introduces no phantom-dependency breakage. diff --git a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md index cff6a4f17e..7969f0e80c 100644 --- a/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -26,14 +26,13 @@ Every graph page declares one maintenance mode: ### First shipped index -The index links twelve relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. +The index links eleven relationship surfaces. Package topology and tool-package affordances live in the existing generated catalogs that already own those facts; the remaining focused diagrams are generated by `scripts/gen-doc-graphs.ts`. | Graph | Maintenance mode | Source of truth | |---|---|---| | [module dependency graph](../../../../docs/module-graph.md) | generated | `packages/*/*/package.json` peer dependencies plus package group paths | | [tool schema catalog and package map](../../../../docs/tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | | [capability seams and core services](../../../../docs/capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | -| [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [tui-agent app composition](../../../../examples/tui-agent/composition.md) | hybrid generated | `examples/tui-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [headless-agent app composition](../../../../examples/headless-agent/composition.md) | hybrid generated | `examples/headless-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [cordis-agent app composition](../../../../examples/cordis-agent/composition.md) | hybrid generated | `examples/cordis-agent/cordis.yml` plugin list plus curated app/bundle expansion | diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index 4609ed505c..507641a99a 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -13,7 +13,7 @@ Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibili Two Node features gate the source runtime: - **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load. -- **Native TypeScript type-stripping** — the built-mode `examples/echo-agent/tests/echo.e2e.ts` smoke boots `dsh-cli-demo`'s published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. +- **Native TypeScript type-stripping** — the built-mode `examples/headless-agent/tests/keyless-smoke.e2e.ts` smoke boots `dsh-cli-demo`'s published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` test adapter (`cli-mock-llm.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`. Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. diff --git a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index ecea052387..d8d4015b0d 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -24,7 +24,7 @@ If an LLM adapter browser or dynamic model-picker needs this signal later, reint ## Verification -`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot expected outputs and the echo-agent smoke are byte-unchanged. +`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and the ACP snapshots plus the keyless Headless Loader smoke pin the unchanged production paths. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 284f33ea18..c44201b4e6 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -2,7 +2,7 @@ Status: implemented -The later [remove-stdio-agent decision](2026-07-20-remove-stdio-agent.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. +The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. ## Problem diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md deleted file mode 100644 index e4dcd37149..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.md +++ /dev/null @@ -1,44 +0,0 @@ -# Agent Note: Remove the line-oriented stdio agent - -Status: implemented - -English | [中文](2026-07-20-remove-stdio-agent.zh.md) - -## Problem - -DeepSeek Harness had two terminal agents after the full-screen TUI shipped. `@deepseek-ai/dsh-tui` owned the interactive coding experience, while `@deepseek-ai/dsh-stdio` retained a line-oriented multi-turn chat protocol for ordinary streams. The latter was no longer a distinct product need: interactive users use the TUI, and scripts need a bounded Headless task with explicit output and exit semantics rather than prompts mixed with model and tool output. - -The redundant surface extended beyond one UI plugin. `@deepseek-ai/dsh-stdio-demo` selected between two terminal modes, `examples/repl-agent` owned a second copy of the coding composition, `demo:repl` exposed it, Loader and built-bin tests drove its prompt protocol, and the SDK generator offered a `stdio` interface that could create new users of the obsolete package. Keeping any of those paths would preserve the line agent indirectly. - -Standard input and output are also used as transport by ACP, the SDK JSON-RPC bridge, subprocesses, and test fixtures. Those byte channels are protocol boundaries, not the line-oriented agent, so removing every generic use of process streams would conflate unrelated designs. - -## Decision - -The line-oriented agent is removed without a compatibility package or mode alias. The `packages/ui/stdio` plugin, `@deepseek-ai/dsh-stdio-demo` package identity, `examples/repl-agent` leaf, `demo:repl` command, prompt/render tests, and supporting manifest, catalog, graph, and documentation entries are deleted. - -The two remaining application roles are explicit: - -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) is the only terminal-interactive app. `examples/tui-agent` owns the complete coding composition and its Code Mode overlay directly; it no longer includes or patches another terminal leaf. -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition and generic real-agent e2e suites, while `examples/echo-agent` supplies the keyless mock task and CI smoke. - -The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and continue to create or resume one exact session. No old option is accepted because the repository is pre-release and has no compatibility promise. - -ACP and JSON-RPC retain their stdio transports. Child-process `stdio` settings and stream-reading APIs also remain where they describe operating-system I/O rather than the removed agent. - -## Verification - -TUI Loader coverage runs the real app under a pseudo-terminal in both source and built modes. Headless Loader coverage proves the mock tool round trip, multi-turn test drivers exercise a single app-owned agent without a UI protocol, and the CLI built-bin suite pins text, JSON, stream-JSON, persistence, failure, and signal behavior. Generated package/config/module graphs reject stale package references. - -## Alternatives considered - -- **Keep the line agent only for pipes** — rejected because Headless already has a clearer bounded-task contract, format-pure stdout, durable completion, and process exit status. -- **Keep the package as a compatibility wrapper over Headless** — rejected because a multi-turn prompt protocol cannot honestly preserve its behavior by delegating to a one-shot CLI, and the pre-release policy favors the correct public surface. -- **Let the TUI fall back when streams are not TTYs** — rejected because silent interface changes hide deployment mistakes; the TUI fails loud and callers select Headless explicitly. -- **Remove every use of the term or mechanism stdio** — rejected because ACP and JSON-RPC intentionally use standard I/O as a framed transport and do not expose the removed line agent. - -## Consequences - -- Terminal interaction has one owner, one app package, one coding leaf, and one test strategy. -- Automation has an explicit task/result contract rather than prompt parsing or EOF-driven conversation control. -- Existing line-agent configurations and SDK `--interface=stdio` invocations fail instead of being translated. -- The TUI requires a TTY pair; non-interactive environments use Headless, ACP, or JSON-RPC according to their protocol needs. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md deleted file mode 100644 index 9f0c2348de..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.zh.md +++ /dev/null @@ -1,44 +0,0 @@ -# Agent Note: 移除面向行的 stdio agent - -Status: implemented - -[English](2026-07-20-remove-stdio-agent.md) | 中文 - -## 问题 - -全屏 TUI 交付后,DeepSeek Harness 同时存在两个终端 agent。`@deepseek-ai/dsh-tui` 负责交互式 coding 体验,而 `@deepseek-ai/dsh-stdio` 仍为普通 stream 保留面向行的多轮聊天协议。后者已不再对应独立的产品需求:交互用户使用 TUI;脚本需要的是具有明确输出和退出语义的有界 Headless 任务,而不是与模型和工具输出混在一起的提示符。 - -重复 surface 不只涉及一个 UI 插件。`@deepseek-ai/dsh-stdio-demo` 在两种终端模式间选择,`examples/repl-agent` 维护第二份 coding 组装,`demo:repl` 对外暴露它,Loader 与 built-bin 测试驱动其提示符协议,SDK 生成器还提供可以创建旧包新用户的 `stdio` interface。保留其中任何路径,都会间接保留面向行的 agent。 - -ACP、SDK JSON-RPC bridge、子进程和测试 fixture 同样使用标准输入输出作为 transport。这些字节通道是协议边界,并不是面向行的 agent;因此,删除所有通用进程 stream 用法会混淆彼此无关的设计。 - -## 决策 - -移除面向行的 agent,不提供兼容 package 或 mode alias。删除 `packages/ui/stdio` 插件、`@deepseek-ai/dsh-stdio-demo` package identity、`examples/repl-agent` 叶节点、`demo:repl` 命令、提示符/渲染测试,以及相关 manifest、catalog、graph 和文档条目。 - -保留的两个应用角色均改为显式选择: - -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 是唯一的终端交互式 app。`examples/tui-agent` 直接拥有完整 coding 组装及其 Code Mode overlay,不再 include 或 patch 另一个终端叶节点。 -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次组装和通用真实 agent e2e suite,`examples/echo-agent` 则提供 keyless mock 任务与 CI smoke。 - -SDK project model 与 create/config workflow 将 `stdio` run-interface 选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并继续创建或恢复一个确切 session。仓库处于 pre-release 阶段且没有兼容性承诺,因此不会接受旧选项。 - -ACP 和 JSON-RPC 保留各自的 stdio transport。描述操作系统 I/O 而非已移除 agent 的子进程 `stdio` 设置与 stream 读取 API 也继续保留。 - -## 验证 - -TUI Loader 覆盖在 source 与 built 两种模式下通过伪终端运行真实 app。Headless Loader 覆盖验证 mock 工具往返;多轮测试 driver 在没有 UI 协议的情况下驱动同一个 app-owned agent;CLI built-bin suite 固定 text、JSON、stream-JSON、持久化、失败和 signal 行为。生成的 package/config/module graph 会拒绝陈旧的 package 引用。 - -## 曾考虑的替代方案 - -- **仅为 pipe 保留面向行的 agent**:不予采纳,因为 Headless 已提供更清晰的有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 -- **保留 package,并将其作为 Headless 的兼容 wrapper**:不予采纳,因为多轮提示符协议无法通过委托给单次 CLI 来诚实地保持行为,而且 pre-release 策略优先选择正确的公开 surface。 -- **让 TUI 在 stream 不是 TTY 时回退**:不予采纳,因为静默切换 interface 会掩盖部署错误;TUI 会快速失败,由调用方显式选择 Headless。 -- **移除 stdio 这个术语或机制的所有用法**:不予采纳,因为 ACP 与 JSON-RPC 有意使用标准 I/O 作为分帧 transport,并不暴露已移除的面向行 agent。 - -## 后果 - -- 终端交互只有一个 owner、一个 app package、一个 coding 叶节点和一套测试策略。 -- 自动化使用显式 task/result 契约,不再解析提示符或通过 EOF 控制对话。 -- 现有面向行的 agent 配置和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 -- TUI 要求成对的 TTY;非交互环境根据协议需要使用 Headless、ACP 或 JSON-RPC。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml similarity index 62% rename from .agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index efa382c208..91e9b078ad 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-agent.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-remove-stdio-agent.md: e4dcd371490e0810900134ab9a05f6f894ca06d0 -2026-07-20-remove-stdio-agent.zh.md: 9f0c2348de27b9c724e6f656881ec316bf48005f +2026-07-20-remove-stdio-and-echo-agents.md: 2aba8193710c96d3726b91062bfa43d039b4cabf +2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c3916683f4743384a2ce4104319da26145837fe diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md new file mode 100644 index 0000000000..2aba819371 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -0,0 +1,45 @@ +# Agent Note: Remove the stdio and Echo agents + +Status: implemented + +English | [中文](2026-07-20-remove-stdio-and-echo-agents.zh.md) + +## Problem + +DeepSeek Harness exposed two redundant product agents beside the TUI and Headless coding agents. The line-oriented stdio agent duplicated terminal interaction and non-interactive execution with a mixed prompt/output protocol. Echo duplicated Headless as a network-free mock model plus one teaching tool, making a test fixture into a user-facing agent and the default quick-start path. + +Both agents carried support surfaces beyond their leaf configurations. Stdio owned a UI plugin, app package, SDK interface, REPL leaf, prompt protocol, and Loader tests. Echo owned a runnable command, mock adapter, tool, CI demo gate, graph entry, teaching references, and a shared test fixture. Keeping any of those product paths would preserve the redundant agent indirectly. + +Standard input and output remain protocol boundaries for ACP, JSON-RPC, MCP, and child processes. Deterministic model adapters also remain valid inside tests. Those mechanisms do not justify a line-oriented or mock-only product agent. + +## Decision + +The stdio and Echo agents are removed without compatibility packages, modes, commands, or aliases. The stdio UI and app packages, `examples/repl-agent`, `examples/echo-agent`, `demo:repl`, `demo:echo`, their dedicated tests, and supporting manifests, gates, graphs, and documentation entries are deleted. + +The remaining application roles are explicit: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. +- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. + +The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. + +Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapter to exercise a real tool round trip, the CLI built-bin suite pins output, persistence, failure, and signal semantics, and package-specific Loader tests keep deterministic adapters beside their scenarios. None is exposed as a runnable mock agent. + +## Verification + +TUI and Headless Loader coverage run the real app packages in source and built modes. TUI uses a pseudo-terminal; Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, and SDK-interface references. + +## Alternatives considered + +- **Keep the line agent only for pipes** — rejected because Headless has a bounded task contract, format-pure stdout, durable completion, and process exit status. +- **Keep Echo as the keyless quick start** — rejected because the first product experience should exercise the real model and supported coding agent, not a scripted adapter with a bespoke tool. +- **Keep Echo only as a CI demo command** — rejected because test-owned Headless fixtures cover the same Loader and built-artifact boundaries without preserving a mock product leaf. +- **Remove every stdio or mock mechanism** — rejected because framed protocols, process I/O, and deterministic test adapters are independent infrastructure, not the removed agents. + +## Consequences + +- Interactive and non-interactive product execution each have one owner and one runnable coding leaf. +- The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`. +- CI retains keyless real-entry coverage through test fixtures rather than a product command. +- Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md new file mode 100644 index 0000000000..2c3916683f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 移除 stdio 和 Echo agent + +Status: implemented + +[English](2026-07-20-remove-stdio-and-echo-agents.md) | 中文 + +## 问题 + +DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个重复的产品 agent(智能体)。面向行的 stdio agent 使用混合的提示符/输出协议,同时重复实现终端交互与非交互执行。Echo 则以无需联网的 mock 模型加一个教学工具重复实现 Headless,把测试 fixture(测试前置数据)变成面向用户的 agent 和默认快速上手路径。 + +两个 agent 的配套实现都不止叶节点配置。stdio 拥有 UI 插件、app 包(package)、SDK 接口、REPL 叶节点、提示符协议和 Loader 测试。Echo 拥有可运行命令、mock 适配器、工具、CI 演示门禁、图谱条目、教学引用和共享测试 fixture。保留其中任何产品路径,都会间接保留这个重复的 agent。 + +标准输入输出仍是 ACP、JSON-RPC、MCP 和子进程的协议边界。确定性模型适配器也仍可用于测试。这些机制不足以成为保留面向行或仅使用 mock 的产品 agent 的理由。 + +## 决策 + +彻底移除 stdio 和 Echo agent,不提供兼容包、模式、命令或别名。删除 stdio UI 包与 app 包、`examples/repl-agent`、`examples/echo-agent`、`demo:repl`、`demo:echo`、各自的专属测试,以及相关的 manifest(元数据清单)、门禁、图谱和文档条目。 + +保留的应用角色均有明确归属: + +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 +- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 + +SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 + +无密钥验证由测试负责。Headless Loader 冒烟测试使用 fixture 适配器验证真实工具往返;CLI built-bin 测试套件固定输出、持久化、失败和信号语义;各包专属的 Loader 测试则将确定性适配器放在对应场景旁。其中任何一项都不会作为可运行的 mock agent 对外暴露。 + +## 验证 + +TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。TUI 使用伪终端;Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点和 SDK 接口引用。 + +## 曾考虑的替代方案 + +- **仅为 pipe 保留面向行 agent**:不予采纳,因为 Headless 已提供有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 +- **保留 Echo 作为无密钥快速上手路径**:不予采纳,因为首次产品体验应使用真实模型和受支持的 coding agent,而不是带专用工具的脚本化适配器。 +- **只为 CI 演示命令保留 Echo**:不予采纳,因为由测试持有的 Headless fixture 可以覆盖相同的 Loader 和构建产物边界,无需保留 mock 产品叶节点。 +- **移除所有 stdio 或 mock 机制**:不予采纳,因为分帧协议、进程 I/O 和确定性测试适配器是独立基础设施,并不是被移除的 agent。 + +## 后果 + +- 交互式与非交互式产品执行分别只有一个归属方和一个可运行的 coding 叶节点。 +- 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。 +- CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。 +- 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index c157853318..133198a4d2 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-18-tui-terminal-state-snapshots.md: 7277e6be6d88a73ea3abbd277e314715c8abc050 -2026-07-18-tui-terminal-state-snapshots.zh.md: 068a8b8a7f469f40492924bf94757bc0e25c3404 +2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd +2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 7277e6be6d..8e86588f69 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [line-agent removal](../simplification/2026-07-20-remove-stdio-agent.md) owns this consolidation. +The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index 068a8b8a7f..b70a46830f 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[面向行 agent 的移除决策](../simplification/2026-07-20-remove-stdio-agent.md)负责此次整合。 +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 84b219c93b..a138c5b4b4 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -5,7 +5,7 @@ description: Use before pushing, force-pushing, marking ready for review, claimi # DSH Pre-Push Checks -Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, demo smoke, and built-bin smoke. +Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, and built-bin smoke. ## First Steps @@ -54,7 +54,7 @@ pnpm run test:snapshot Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. ```sh -DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/echo-agent/tests/echo.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts ``` Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. diff --git a/AGENTS.md b/AGENTS.md index 12461b55ec..860f60f1e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,6 @@ pnpm run build # tsc emits lib/types, tsdown bundles runtime pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json pnpm run website:build # VitePress build (doubles as the site's dead-link check) -pnpm run demo:echo "task" # mock-model headless agent, no key needed pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) @@ -85,12 +84,7 @@ pnpm run website:build pnpm run verify-module-graph pnpm run build pnpm run hygiene -out=$(pnpm run demo:echo --output-format stream-json -- "echo ci smoke" 2>&1) -printf '%s\n' "$out" | grep -q '"type":"tool/call"' -printf '%s\n' "$out" | grep -q 'ECHO: CI SMOKE' -test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" -rm -rf .sessions -DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/echo-agent/tests/echo.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts examples/headless-agent/tests/keyless-smoke.e2e.ts examples/tui-agent/tests/tui-keyless-smoke.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. diff --git a/README.i18n.yaml b/README.i18n.yaml index ad069ecd3e..d78213a292 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4ce9391e9286391a601d59d8401870c9ca8c79f3 -README.zh.md: c4f996762b36d2ebe00cd256e2f18b0a62550ba8 +README.md: 32958db0e74bd14d6d41e8d7886b8d3257fe0f59 +README.zh.md: b28b175a8296347a7bed05b4e53c0d75dc51efed diff --git a/README.md b/README.md index 4ce9391e92..32958db0e7 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo "task" # keyless mock-model headless agent -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) +# Agent demos require DEEPSEEK_API_KEY. +pnpm run demo:tui # full-screen TUI coding agent +pnpm run demo:headless "task" # one-shot coding agent +pnpm run demo:cordis # self-referential agent demo +pnpm run demo:acp # ACP server agent demo ``` For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) and [documentation graph index](docs/graph-atlas.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). diff --git a/README.zh.md b/README.zh.md index c4f996762b..b28b175a82 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,11 +11,11 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo "task" # keyless mock-model headless agent -pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:headless "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) -pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) +# Agent demos require DEEPSEEK_API_KEY. +pnpm run demo:tui # full-screen TUI coding agent +pnpm run demo:headless "task" # one-shot coding agent +pnpm run demo:cordis # self-referential agent demo +pnpm run demo:acp # ACP server agent demo ``` 面向开发者:先读[开发指南](docs/development.md),了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)和[文档关系图索引](docs/graph-atlas.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 5bcb3bac1d..070cc45f93 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-a-tool.md: 68a8449bc189497b917efe678837d757f85aaf75 -adding-a-tool.zh.md: 003534e04550bfbee6740aa3b6bee02ac2cdc237 +adding-a-tool.md: a45315dc0ec92ab28963c2aca32dffcf5f778dcd +adding-a-tool.zh.md: f574957ddd0e42cedc93ddc0f3270110a8f110c5 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 68a8449bc1..a45315dc0e 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -2,7 +2,7 @@ English | [中文](adding-a-tool.zh.md) -How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam). +How to give the model a new capability. The minimal shape below shows the contract; `packages/bash/tool-bash` is the production-grade three-package seam. ## The minimal shape diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 003534e045..f574957ddd 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -2,7 +2,7 @@ [English](adding-a-tool.md) | 中文 -如何为模型赋予一项新能力。参考实现:`examples/echo-agent/src/echo-tool.ts`(最小化)和 `packages/bash/tool-bash`(生产级,由三个包(package)构成的 seam)。 +如何为模型赋予一项新能力。下文的最小形态展示这项契约;`packages/bash/tool-bash` 是生产级、由三个包(package)构成的 seam。 ## 最小形态 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 9818bb8671..940fb81a0f 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 811eeb04d1730a8062454f932477d1e05275f3cf -extension-cookbook.zh.md: a1c22aeffcd337401bed9444ebd52ebd5b524595 +extension-cookbook.md: 32877b6170fd75ec901dda7cc0aec6b5a92e6cc6 +extension-cookbook.zh.md: d6bb6075b47867dcb5a848c835062ef4d9af5d45 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 811eeb04d1..32877b6170 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Five runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (keyless mock model + echo tool through Headless, `pnpm run demo:echo "task"`), [`examples/tui-agent`](../../examples/tui-agent) (DeepSeek coding tools through the full-screen TUI, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the coding capabilities behind a one-shot task and DSH-native output, `pnpm run demo:headless "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting through the TUI, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). Interactive leaves load [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves load [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Four runnable leaves load their plugin trees from `cordis.yml`: [`examples/tui-agent`](../../examples/tui-agent) (DeepSeek coding tools through the full-screen TUI, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the coding capabilities behind a one-shot task and DSH-native output, `pnpm run demo:headless "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting through the TUI, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). Interactive leaves load [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves load [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index a1c22aeffc..d6bb6075b4 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -五个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(通过 Headless 运行的 keyless mock 模型 + echo 工具,`pnpm run demo:echo "task"`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 TUI 运行的 DeepSeek coding 工具,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(通过单次任务和 DSH 原生输出运行的 coding 能力,`pnpm run demo:headless "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(通过 TUI 进行自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。交互式叶子加载 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 +四个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 TUI 运行的 DeepSeek coding 工具,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(通过单次任务和 DSH 原生输出运行的 coding 能力,`pnpm run demo:headless "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(通过 TUI 进行自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。交互式叶子加载 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d05265a584..8fa0107cfc 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 6517a14f094a5098c26815e04f81e3f8ea1ceff9 -development.zh.md: bb70970679aaa8ffaed927b746002277cb422f6b +development.md: 3327d094a31ad9af62a23c9562cdfa03218961a5 +development.zh.md: cb1cb86f9f3a34c455844467a20dfdfd08e15098 diff --git a/docs/development.md b/docs/development.md index 6517a14f09..3327d094a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -63,7 +63,7 @@ lefthook is configured in `lefthook.yml` as an early local checkpoint before rev The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26. +These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26. ## CI gates @@ -102,10 +102,10 @@ When changing package public behavior, update the relevant README or JSDoc in th ## Demos -The Headless echo demo does not need API credentials: +The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:echo "echo hello" +pnpm run demo:headless "summarize this workspace" ``` The full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: diff --git a/docs/development.zh.md b/docs/development.zh.md index bb70970679..cb1cb86f9f 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -63,7 +63,7 @@ lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 +这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 ## CI 门禁 @@ -102,10 +102,10 @@ pnpm run hygiene # knip, publint, workspace constraints, and NodeNext dec ## 演示 -Headless echo 演示不需要 API 凭证: +单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm run demo:echo "echo hello" +pnpm run demo:headless "summarize this workspace" ``` 全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 516d041166..6050c4a60e 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -12,7 +12,6 @@ The process decision behind this index is recorded in [the documentation graph A | [module dependency graph](module-graph.md) | `generated` | | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | -| [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | | [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | | [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | diff --git a/docs/testing.md b/docs/testing.md index 84629aae45..19cd60ebdb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,7 +11,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## The with-key policy: inference is cheap here -We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships a keyless smoke and — unless keyless-by-nature — a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). +We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). ## Prefer the real implementation over a mock diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 22b03af93e..711715a5de 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -index.md: 5fa46806bc195ad2566fc0a29b45eb1dd7a68179 -index.zh.md: a6d238c12841c8c25b00376ee032e5db50fc6b4e +index.md: d7d657ff7b8cb9001dd5e9c3af658a7a3c45b5b7 +index.zh.md: 7a134f7aaed470b87ee8ca8978dd39593de2651b diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 5fa46806bc..d7d657ff7b 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -122,24 +122,24 @@ Function form is sufficient in most cases. Use class form when the plugin provid ## Complete example -`examples/echo-agent/src/echo-tool.ts` is a plugin that registers a tool: +A minimal tool plugin registers its definition on `ctx.tools`: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'echo-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', + name: 'greet', + description: 'Greet the named person.', parameters: { - text: { type: 'string', required: true }, + name: { type: 'string', required: true }, }, async execute(args) { - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) } diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index a6d238c128..7a134f7aae 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -122,24 +122,24 @@ export default class MyService extends Service { ## 完整示例 -参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: +最小化的工具插件会在 `ctx.tools` 上注册其定义: ```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -export const name = 'echo-tool' +export const name = 'greet-tool' export const inject = ['tools'] export function apply(ctx: Context) { ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', + name: 'greet', + description: 'Greet the named person.', parameters: { - text: { type: 'string', required: true }, + name: { type: 'string', required: true }, }, async execute(args) { - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) } diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 945b79ab3c..8735e8d5a6 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-adapter.md: 83296e54220c668410fe69d689199171251a7787 -llm-adapter.zh.md: 89b7185690dbdfe33cbafe7b0ee4c3e83cfe0df8 +llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa +llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 83296e5422..3e83289b80 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -145,9 +145,8 @@ The repository contains complete implementations: - `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format - `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format -- `examples/echo-agent/src/mock-llm.ts` — minimal local teaching adapter -Start with the mock adapter to study a complete chunk sequence without network behavior. +Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs. ## Error handling diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 89b7185690..92fcf9b22f 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -145,9 +145,8 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) -- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) -mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 +对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。 ## 错误处理 diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index bbe68e65e9..cf2658bf4d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -config.md: 0616f163f995b152d7a28841506027558de2c32c -config.zh.md: fa91445ae88456a61a4736ce8b71aed482227ce8 +config.md: 8958729d04224215ca420c3103d253a8a5783405 +config.zh.md: 530f2b335453d5064acdac28a60d7df51cd915f0 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index 0616f163f9..8958729d04 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,7 +8,6 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [echo-agent](../../../examples/echo-agent/cordis.yml) uses a local mock model and needs no API key. - [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) connects to editor clients over ACP. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index fa91445ae8..530f2b3354 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,7 +8,6 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [echo-agent](../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 - [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index c3086cd4fd..b3de74949b 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -quickstart.md: 62e899adfa33e566083b8224b9bdf77c1038557a -quickstart.zh.md: 382c9685ebe0c919c2fd039484898b44e9264aa7 +quickstart.md: 25ce51ee3d010d2eb800071b9697fc62857dace1 +quickstart.zh.md: e2e023670a999566e273d8893c42103dc273e7b1 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 62e899adfa..25ce51ee3d 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -8,6 +8,7 @@ This guide gets an agent running in five minutes. - [Node.js](https://nodejs.org/) ^22.19 or >= 24 - [pnpm](https://pnpm.io/) 11 through Corepack +- A [DeepSeek Platform](https://platform.deepseek.com/) API key ```sh node -v @@ -15,25 +16,32 @@ corepack enable pnpm -v ``` -## Step 1: run the keyless Headless demo +## Step 1: install and configure the API key ```sh git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness pnpm install -pnpm run demo:echo "echo hello world" ``` -The local mock model calls the `echo` tool, which returns the text in uppercase, and the final response is printed without opening an interactive UI. Use `--output-format stream-json` when you need the canonical event stream. - -## Step 2: use a real model in the TUI - -Get an API key from [DeepSeek Platform](https://platform.deepseek.com/) and create the gitignored repository-root `.env`: +Create the gitignored repository-root `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` +## Step 2: run one Headless task + +Run a non-interactive task and print its final answer: + +```sh +pnpm run demo:headless "summarize the architecture of this workspace" +``` + +Headless runs one complete model/tool turn, persists the session, prints the result, and exits. Use `--output-format stream-json` when you need the canonical event stream. + +## Step 3: use the TUI + Start the interactive coding agent: ```sh @@ -44,7 +52,7 @@ The full-screen agent can read and write files, run commands, delegate subtasks, ## What happened -echo-agent uses the Headless `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the model and capability plugins appropriate to each surface. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 382c9685eb..e2e023670a 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -8,6 +8,7 @@ - [Node.js](https://nodejs.org/) ^22.19 或 >= 24 - 通过 Corepack 使用 [pnpm](https://pnpm.io/) 11 +- [DeepSeek Platform](https://platform.deepseek.com/) API key ```sh node -v @@ -15,25 +16,32 @@ corepack enable pnpm -v ``` -## 第一步:运行 keyless Headless 演示 +## 第一步:安装并配置 API key ```sh git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness pnpm install -pnpm run demo:echo "echo hello world" ``` -本地 mock 模型会调用 `echo` 工具,由工具返回大写文本,最终回复在不打开交互式 UI 的情况下直接输出。需要规范事件流时可使用 `--output-format stream-json`。 - -## 第二步:在 TUI 中使用真实模型 - -前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取 API key,并创建已被 Git 忽略的仓库根目录 `.env`: +在仓库根目录创建已被 Git 忽略的 `.env`: ```sh DEEPSEEK_API_KEY=sk-your-key-here ``` +## 第二步:运行一个 Headless 任务 + +运行一个非交互式任务并打印最终回答: + +```sh +pnpm run demo:headless "summarize the architecture of this workspace" +``` + +Headless 运行一个完整的模型/工具轮次,持久化会话,打印结果后退出。需要规范事件流时可使用 `--output-format stream-json`。 + +## 第三步:使用 TUI + 启动交互式 coding agent: ```sh @@ -44,7 +52,7 @@ pnpm run demo:tui ## 回头看 -echo-agent 使用 Headless `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index dde6e5e522..a1f87ac8fb 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -11,8 +11,6 @@ Each example has both: - **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). -Mock-only examples require only the keyless tier; state that exception in the test. - Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/README.md b/examples/README.md index f8489a4c1b..8578c5c25a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,17 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools or demo-only mocks. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. - -## echo-agent - -A mock model + echo tool on the headless one-shot app — the all-mock skeleton. It demonstrates: - -- A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-cli-demo` app -- Registering a mock `LlmAdapter` (streaming scripted responses) -- Registering a tool via `ctx.tools.register()` -- A network-free Headless task with text or DSH-native JSON output - -Run with: `pnpm run demo:echo "echo hello"`. The task prefix `echo ` triggers a tool-call round trip. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. ## headless-agent diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md deleted file mode 100644 index 4503705439..0000000000 --- a/examples/echo-agent/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# echo-agent - -Network-free Headless demo with a scripted mock model and an echo tool. - -## What it shows - -The leaf loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), which supplies the shared spine, JSONL persistence, one fresh `main` agent, and the one-shot CLI driver. Two local plugins provide the demo behavior: - -- `mock-llm.ts` registers a scripted `LlmAdapter`; a task beginning with `echo ` requests the tool. -- `echo-tool.ts` registers a typed tool that returns the input uppercased. - -| File | Role | -|---|---| -| `src/mock-llm.ts` | Streaming mock adapter | -| `src/echo-tool.ts` | Model-facing echo tool | -| `cordis.yml` | Mock plugins, local providers, and one `@deepseek-ai/dsh-cli-demo` entry | - -## Run - -```sh -pnpm run demo:echo "echo hello world" -pnpm run demo:echo --output-format stream-json -- "echo hello world" -``` - -The first command prints the final canned response. `stream-json` also exposes the canonical `tool/call` and `tool/result` events. Sessions persist under `.sessions/` relative to the launch directory; remove that generated directory when finished. diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md deleted file mode 100644 index 1aadfda08c..0000000000 --- a/examples/echo-agent/composition.md +++ /dev/null @@ -1,40 +0,0 @@ - - -# Echo Agent App Composition - -The echo demo swaps in a local mock LLM and teaching echo tool, then loads the headless one-shot app package. - -```mermaid -flowchart LR - cfg["examples/echo-agent
cordis.yml"] - plugin_echo_mock_llm["mock-llm
./src/mock-llm.ts"] - cfg --> plugin_echo_mock_llm - plugin_echo_echo_tool["echo-tool
./src/echo-tool.ts"] - cfg --> plugin_echo_echo_tool - plugin_echo_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_echo_bash - plugin_echo_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_echo_fs_local - plugin_echo_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] - cfg --> plugin_echo_cli_agent - plugin_echo_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_echo_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_echo_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
fresh top-level agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] -``` - -| Plugin id | Package / module | -| --- | --- | -| `mock-llm` | `./src/mock-llm.ts` | -| `echo-tool` | `./src/echo-tool.ts` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `cli-agent` | `@deepseek-ai/dsh-cli-demo` | - -Source config: [`examples/echo-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml deleted file mode 100644 index 967c01f6a5..0000000000 --- a/examples/echo-agent/cordis.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Headless agent with the network-free `mock-echo` adapter and example-local -# `echo` tool. No API key is needed because the adapter never touches the network. - -- id: mock-llm - name: './src/mock-llm.ts' - -- id: echo-tool - name: './src/echo-tool.ts' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - provider: mock - model: mock-echo - persona: 'You are echo-agent, a demo agent.' - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 diff --git a/examples/echo-agent/package.json b/examples/echo-agent/package.json deleted file mode 100644 index c3c4fd5553..0000000000 --- a/examples/echo-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "echo-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable headless demo: scripted mock model + echo tool" -} diff --git a/examples/echo-agent/src/echo-tool.ts b/examples/echo-agent/src/echo-tool.ts deleted file mode 100644 index dfdcb9b001..0000000000 --- a/examples/echo-agent/src/echo-tool.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Context } from 'cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' - -export const name = 'echo-tool' -export const inject = ['tools'] - -export function apply(ctx: Context) { - ctx.tools.register(defineTool({ - name: 'echo', - description: 'Echo the given text back, uppercased.', - parameters: { - text: { type: 'string', required: true }, - }, - async execute(args) { - // args is typed: { text: string } - return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] - }, - })) -} diff --git a/examples/echo-agent/src/mock-llm.ts b/examples/echo-agent/src/mock-llm.ts deleted file mode 100644 index 1f61dc4ee3..0000000000 --- a/examples/echo-agent/src/mock-llm.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from 'cordis' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' - -/** - * Demo adapter for the `mock-echo` model. - * - * Behavior: if the last user text starts with "echo ", it calls the `echo` - * tool with the rest of the line (exercising the tool round-trip), otherwise - * it streams a canned reply quoting the input. - */ -class MockEchoAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const lastUserText = [...options.messages].reverse() - .filter(message => message.role === 'user') - .flatMap(message => message.content) - .filter(block => block.type === 'text') - .map(block => block.text) - .find(text => !text.startsWith('<')) ?? '' - - const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') - - if (lastUserText.startsWith('echo ') && !hasToolResult) { - const payload = lastUserText.slice(5) - const args = JSON.stringify({ text: payload }) - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of 'Let me echo that for you.') { - yield { type: 'text-delta', index: 0, text: char } - await new Promise(resolve => setTimeout(resolve, 2)) - } - yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Let me echo that for you.' } } - yield { type: 'block-start', index: 1, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 1, id: CallId('call-echo'), name: 'echo', argumentsDelta: args } - yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-echo'), name: 'echo', arguments: args } } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - return - } - - const reply = hasToolResult - ? 'The echo tool has spoken.' - : `You said: "${lastUserText}". Try "echo " to see a tool call.` - yield { type: 'block-start', index: 0, blockType: 'text' } - for (const char of reply) { - yield { type: 'text-delta', index: 0, text: char } - await new Promise(resolve => setTimeout(resolve, 2)) - } - yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } - yield { type: 'usage', usage: { inputTokens: 20, outputTokens: reply.length } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'mock-llm' -export const inject = ['llm'] - -export function apply(ctx: Context) { - ctx.llm.registerAdapter(['mock'], new MockEchoAdapter()) -} diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts deleted file mode 100644 index cf15e22c5a..0000000000 --- a/examples/echo-agent/tests/echo.e2e.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -async function runEcho(task: string, outputFormat: 'text' | 'stream-json' = 'text'): Promise { - const { stdout } = await runLoaderSmoke({ - label: 'echo-agent', - tempDirPrefix: 'echo-smoke-', - binScript, - configPath, - binArgs: ['--config', configPath, '--output-format', outputFormat, task], - tsconfigPath, - }) - return stdout -} - -describe('echo-agent keyless smoke (Headless through the real Loader tree)', () => { - it('runs the echo tool round-trip and exposes both events in stream-json', async () => { - const lines = (await runEcho('echo hello world', 'stream-json')) - .trimEnd().split('\n').map(line => JSON.parse(line) as Record) - const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) - expect(events.some(event => event.type === 'tool/call' && event.data.name === 'echo')).toBe(true) - expect(JSON.stringify(events.find(event => event.type === 'tool/result'))).toContain('ECHO: HELLO WORLD') - expect(lines.at(-1)).toMatchObject({ type: 'result', success: true }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('prints the final canned reply for a direct one-shot task', async () => { - const stdout = await runEcho('just chatting') - expect(stdout).toContain('You said: "just chatting"') - expect(stdout).not.toContain('tool/call') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/echo-agent/tests/fixtures/context/time-context/driver.ts b/examples/headless-agent/tests/fixtures/time-context-driver.ts similarity index 89% rename from examples/echo-agent/tests/fixtures/context/time-context/driver.ts rename to examples/headless-agent/tests/fixtures/time-context-driver.ts index ea73745faf..cac81daeec 100644 --- a/examples/echo-agent/tests/fixtures/context/time-context/driver.ts +++ b/examples/headless-agent/tests/fixtures/time-context-driver.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -/** Test driver that sends two turns through one headless Loader composition. */ +/** Test driver that sends two turns through one Headless Loader composition. */ import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' diff --git a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts new file mode 100644 index 0000000000..8cd3155ca7 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the time-context Loader fixture. */ +class TimeContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'time context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'time-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `time-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['time-context-mock'], new TimeContextMockAdapter()) +} diff --git a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml similarity index 74% rename from examples/echo-agent/tests/fixtures/context/time-context/cordis.yml rename to examples/headless-agent/tests/fixtures/time-context.cordis.yml index 8d55a8ac55..59afc77e34 100644 --- a/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -1,6 +1,6 @@ # Test-only composition: keep time-context opt-in while exercising its real Loader/app path. -- id: mock-llm - name: '../../../../src/mock-llm.ts' +- id: time-context-mock-llm + name: './time-context-mock-llm.ts' - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -11,8 +11,8 @@ - id: cli-agent name: '@deepseek-ai/dsh-cli-demo' config: - provider: mock - model: mock-echo + provider: time-context-mock + model: time-context-mock persona: 'Test the time-context plugin.' persistenceRoot: './.sessions' workspaceContext: false diff --git a/knip.json b/knip.json index 129f181e9f..77964077f2 100644 --- a/knip.json +++ b/knip.json @@ -9,8 +9,9 @@ }, "examples": { "entry": [ - "echo-agent/src/*.ts", "headless-agent/tests/fixtures/cli-mock-llm.ts", + "headless-agent/tests/fixtures/time-context-driver.ts", + "headless-agent/tests/fixtures/time-context-mock-llm.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" diff --git a/package.json b/package.json index 7ed2c83a04..5c831f6497 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-agent-note-classification && pnpm run verify-agent-note-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "demo:echo": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/echo-agent/cordis.yml", "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index d2532dc8d7..2a0c06fe51 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -8,11 +8,11 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l // Keep the Loader config under examples so both modes exercise the same deployable // topology: local fixture source plus bare plugins owned by the examples workspace. const driver = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/context/time-context/driver.ts', + '../../../../examples/headless-agent/tests/fixtures/time-context-driver.ts', import.meta.url, )) const configPath = fileURLToPath(new URL( - '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml', + '../../../../examples/headless-agent/tests/fixtures/time-context.cordis.yml', import.meta.url, )) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e2f70bbd8c..35d18f6483 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -435,14 +435,6 @@ function stripYamlScalar(value: string): string { } const APP_EXAMPLES = [ - { - id: 'echo', - rel: 'examples/echo-agent/composition.md', - title: 'Echo Agent App Composition', - label: 'examples/echo-agent', - config: 'examples/echo-agent/cordis.yml', - summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the headless one-shot app package.', - }, { id: 'tui', rel: 'examples/tui-agent/composition.md', @@ -1013,7 +1005,6 @@ function renderDocs(): GraphDoc[] { function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', - 'examples/echo-agent/composition.md': 'echo-agent app composition', 'examples/headless-agent/composition.md': 'headless-agent app composition', 'examples/tui-agent/composition.md': 'tui-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', @@ -1025,7 +1016,6 @@ function renderIndex(docs: GraphDoc[]): string { } const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', - 'examples/echo-agent/composition.md': 'hybrid generated', 'examples/headless-agent/composition.md': 'hybrid generated', 'examples/tui-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1e2924bd33..bd0fbb1c88 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -5,9 +5,8 @@ * independent commands can overlap and which commands wait for built artifacts. */ import { spawn } from 'node:child_process' -import { readdir, rm } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' type Mode = @@ -210,7 +209,6 @@ function ciPrimaryGates(): Gate[] { pnpmScript('duplication', 'duplication'), coverageGate(), snapshotGate(), - demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), @@ -229,18 +227,12 @@ function ciStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - ...staticDemoSmokeGates(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] } -function staticDemoSmokeGates(): Gate[] { - // Native Windows session persistence is outside the gates-only support scope. - return process.platform === 'win32' ? [] : [demoSmokeGate()] -} - function ciArtifactGates(): Gate[] { return [ pnpmScript('build', 'build'), @@ -353,49 +345,13 @@ function docSyncLeafGates(options: { ] } -function demoSmokeGate(options: { needs?: string[] } = {}): Gate { - const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs } - return { - id: 'demo-smoke', - label: 'demo smoke', - displayCommand: 'pnpm run demo:echo --output-format stream-json -- "echo ci smoke"', - ...pnpmInvocation(['run', 'demo:echo', '--output-format', 'stream-json', '--', 'echo ci smoke']), - ...dependencyOptions, - verify: async (result) => { - const output = result.stdout + result.stderr - const sessionsRoot = join(root, '.sessions') - try { - if (!output.includes('"type":"tool/call"') || !output.includes('"name":"echo"')) { - throw new Error('demo smoke did not show the echo tool call.') - } - if (!output.includes('ECHO: CI SMOKE')) { - throw new Error('demo smoke did not show the echo tool result.') - } - const buckets = await readdir(sessionsRoot, { withFileTypes: true }) - let found = false - for (const bucket of buckets) { - if (!bucket.isDirectory() || !bucket.name.startsWith('cwd-')) continue - const entries = await readdir(join(sessionsRoot, bucket.name)) - if (entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) { - found = true - break - } - } - if (!found) throw new Error('demo smoke did not create a main-session JSONL log in a cwd bucket.') - } finally { - await rm(sessionsRoot, { recursive: true, force: true }) - } - }, - } -} - function builtBinSmokeGate(): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', '--config', 'vitest.e2e.config.ts', - 'examples/echo-agent/tests/echo.e2e.ts', + 'examples/headless-agent/tests/keyless-smoke.e2e.ts', 'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', From 1dcfc63a789ae207a7bb61fd6ad860ab1da6fd6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:26:26 +0800 Subject: [PATCH 117/132] docs(goal): refresh event source links --- docs/cordis-catalog/events.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index dfe0fdb502..92ccab90a8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:314`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:210`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:229`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:232`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:291`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:294`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:301`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` From b5d112a251915e89cca4ed087b8465c07961b8a4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:40:44 +0800 Subject: [PATCH 118/132] Skip TUI PTY smoke on Windows --- examples/tui-agent/tests/tui-keyless-smoke.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index e2fa7377c6..21be35eef5 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -8,7 +8,8 @@ const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +// The Python PTY driver imports the POSIX-only pty and termios modules. +describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent boot', From 9ef673a5a3c2375680f64ae88ab611b188cedd73 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:47:24 +0800 Subject: [PATCH 119/132] test(commands): cover current ACP scenarios --- .../tests/snapshots/fs-escalation-approved/stdout.expected.jsonl | 1 + .../snapshots/subagent-depth-two-rejection/stdout.expected.jsonl | 1 + 2 files changed, 2 insertions(+) diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl index b8b2f245ee..c42d0d5d56 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl index 7f629a2d71..496f7568b8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -1,5 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}} From 29729f83cf101be3be334a84f93e3a1cf7eaf162 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:59:22 +0800 Subject: [PATCH 120/132] Test the TUI through ConPTY on Windows --- examples/package.json | 3 + examples/tui-agent/tests/pty-harness.ts | 135 +++++++++++++----- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 3 +- pnpm-lock.yaml | 16 +++ pnpm-workspace.yaml | 2 + 5 files changed, 124 insertions(+), 35 deletions(-) diff --git a/examples/package.json b/examples/package.json index 53c392cd4c..f9956c498d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -50,5 +50,8 @@ "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" + }, + "devDependencies": { + "node-pty": "1.1.0" } } diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 21d0b4c9d7..116f7cc9a1 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -2,9 +2,9 @@ import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -const PTY_DRIVER = String.raw` +const POSIX_PTY_DRIVER = String.raw` import errno, json, os, pty, select, signal, sys, time node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:] env = os.environ.copy() @@ -71,9 +71,103 @@ export interface TuiPtySmokeOptions { readonly timeoutMs?: number } +function definedEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) +} + +async function runPosixPtySmoke( + launch: ExampleLaunch, + cwd: string, + options: TuiPtySmokeOptions, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, timeoutMs + 5_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve(stdout) + else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) +} + +async function runWindowsPtySmoke( + launch: ExampleLaunch, + cwd: string, + options: TuiPtySmokeOptions, + timeoutMs: number, +): Promise { + const pty = await import('node-pty') + return await new Promise((resolve, reject) => { + const actions = options.actions ?? [] + const expectedExitCode = options.expectedExitCode ?? 0 + let output = '' + let actionIndex = 0 + let timedOut = false + const terminal = pty.spawn(launch.command, launch.args, { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd, + env: definedEnv({ + ...process.env, + ...launch.env, + COLUMNS: '100', + LINES: '30', + }), + }) + const timer = setTimeout(() => { + timedOut = true + terminal.kill() + }, timeoutMs) + terminal.onData((chunk) => { + output += chunk + while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { + terminal.write(actions[actionIndex]!.send) + actionIndex += 1 + } + }) + terminal.onExit(({ exitCode, signal }) => { + clearTimeout(timer) + if (timedOut) { + reject(new Error(`${options.label} PTY process did not exit before ${String(timeoutMs)}ms. output:\n${output}`)) + } else if (actionIndex !== actions.length) { + reject(new Error(`${options.label} completed ${String(actionIndex)}/${String(actions.length)} PTY actions. output:\n${output}`)) + } else if (exitCode !== expectedExitCode) { + reject(new Error(`${options.label} expected exit ${String(expectedExitCode)}, got ${String(exitCode)} (signal ${String(signal)}). output:\n${output}`)) + } else { + resolve(output) + } + }) + }) +} + /** - * Boot an example in a real pseudo-terminal, drive marker-gated input, and - * return the captured terminal bytes after the expected process exit. + * Boot an example in a real pseudo-terminal (ConPTY on Windows), drive + * marker-gated input, and return captured bytes after the expected process exit. * @param options - launch paths, environment, actions, and expected exit code. * @returns complete pseudo-terminal output. */ @@ -92,35 +186,10 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise { - const child = spawn('python3', [ - '-c', - PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) - }) + if (process.platform === 'win32') { + return await runWindowsPtySmoke(launch, cwd, options, timeoutMs) + } + return await runPosixPtySmoke(launch, cwd, options, timeoutMs) } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 21be35eef5..e2fa7377c6 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -8,8 +8,7 @@ const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The Python PTY driver imports the POSIX-only pty and termios modules. -describe.skipIf(process.platform === 'win32')('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent boot', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c3c84521..977f5afb28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,10 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:* version: link:../packages/workflow/workflow-workerthread + devDependencies: + node-pty: + specifier: 1.1.0 + version: 1.1.0 packages/bash/bash: devDependencies: @@ -6179,6 +6183,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: resolution: {integrity: sha512-oJsXcC33qKl9mWYx0n9YPJ2pUAoY39PoIX0Gx4lDrSCTEvENFrEaODAsQYNY+eEGpn9YMN7E+FOftvea3/1FqQ==} engines: {node: '>=20'} @@ -6256,6 +6263,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} @@ -10605,6 +10615,8 @@ snapshots: neo-async@2.6.2: {} + node-addon-api@7.1.1: {} + node-addon-landlock-run-linux-arm64@0.0.0-test.0: optional: true @@ -10673,6 +10685,10 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + non-layered-tidy-tree-layout@2.0.2: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26bfaeeb0b..55947e1131 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,8 @@ peerDependencyRules: allowBuilds: esbuild: true lefthook: true + # Cross-platform PTY boundary for the TUI process smoke, including ConPTY on Windows. + node-pty: true # Pulled in by @earendil-works/pi-ai (optional LLM API backend). pnpm lists # them only because they ship lifecycle scripts, but those are no-ops we don't # need, so we deny them — install still succeeds. From ad021067e0d8b6e55cbb875cbdcdb71a098492f4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:03:31 +0800 Subject: [PATCH 121/132] fix: preserve transport cause diagnostics --- packages/llm/llm-deepseek/src/adapter.ts | 6 +----- packages/llm/llm-deepseek/tests/adapter.spec.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f8a8516cae..74adafb225 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -57,10 +57,6 @@ function requestId(headers: Headers): ReturnType | und return value === null || value.length === 0 ? undefined : ProviderRequestId(value) } -function errorMessage(value: unknown): string { - return value instanceof Error ? value.message : String(value) -} - /** * Map an HTTP status to a stable LlmError code. * @param status - status of a non-2xx provider response. @@ -144,7 +140,7 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) } if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek transport failed: ${errorMessage(error)}`, 'TRANSPORT', { cause: error }) + throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error }) } finally { consumer.abort('DeepSeek stream consumer stopped') if (!exhausted && iterator.return !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 82ef7506be..0b2dae67bf 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -373,14 +373,20 @@ describe('DeepSeekAdapter against a mock server', () => { } }) - it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => { + it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => { const server = await mockServer([{ kind: 'close-early', events: ['{"choices":[{"delta":{"content":"par"}}]}'], }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/terminated|socket|without \[DONE\]/) + let caught: unknown + try { + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + } catch (error: unknown) { + caught = error + } + expect(caught).toMatchObject({ code: 'TRANSPORT' }) + expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/) }) it('aborts mid-stream via the request signal', async () => { From 7c55ec9038902fd7a3a2c531927ee93317ea5444 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:08:06 +0800 Subject: [PATCH 122/132] docs: condense recovery architecture contract --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 292a15a049..321612c814 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variab Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. -Optional pruning precedes summaries, and `dsh-compact-basic` retries context overflow only after durable surface progress; `dsh-llm-retry` applies bounded transient backoff. Their independent budgets compose on `agent/request-error`, and cancellation wins ([compaction decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [transient-recovery decision](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). ### Failure Boundaries From e9aef281a1f40fa6ab8fc7f05be0cea2ade8774d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:11:38 +0800 Subject: [PATCH 123/132] Document the Windows TUI contract --- .../2026-07-20-windows-tui-support.i18n.yaml | 6 ++++ .../feature/2026-07-20-windows-tui-support.md | 33 +++++++++++++++++++ .../2026-07-20-windows-tui-support.zh.md | 33 +++++++++++++++++++ packages/ui/tui/README.md | 2 ++ 4 files changed, 74 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-20-windows-tui-support.md create mode 100644 .agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml new file mode 100644 index 0000000000..34b6fe5c07 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-windows-tui-support.md: 1771d0c8f71b5273c333b00025f8b58e0f74a868 +2026-07-20-windows-tui-support.zh.md: eb6ada8cb80c232bb92624893ddd19a575fa4bb7 diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md new file mode 100644 index 0000000000..1771d0c8f7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md @@ -0,0 +1,33 @@ +# Agent Note: Support the TUI on Windows + +Status: implemented + +English | [中文](2026-07-20-windows-tui-support.zh.md) + +## Problem + +The full-screen TUI delegates raw input, ANSI rendering, resize events, and terminal restoration to pi-tui's `ProcessTerminal`. That dependency contains a native Windows console path, but the repository's real-process smoke used Python's POSIX-only `pty` and `termios` modules. Skipping that smoke on Windows would leave the supported product path without coverage for startup, input, interaction, failure reporting, or restoration. + +The TUI platform contract must follow the runtime shipped to users rather than the portability of one test driver. A platform exclusion is justified only when the product has an unsupported runtime dependency or a demonstrated semantic gap. + +## Decision + +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) supports interactive terminals on Windows as well as macOS and Linux. The product continues to use pi-tui's `ProcessTerminal`; on Windows it enables virtual-terminal input after raw mode and avoids the Unix-only `SIGWINCH` refresh. DeepSeek Harness adds no platform rejection or reduced Windows mode. + +The real Loader smoke selects a native pseudo-terminal boundary by host. macOS and Linux retain the Python POSIX PTY driver. Windows uses `node-pty` and ConPTY. Both drivers receive the same launch command, environment, terminal dimensions, marker-gated input actions, timeout, expected exit code, and output assertions, and all three smoke scenarios run on every supported platform. + +`node-pty` is a test-only dependency of the examples workspace. Its reviewed native install script is explicitly enabled in `pnpm-workspace.yaml`; production TUI packages do not acquire a new dependency or subprocess layer. + +## Alternatives considered + +- **Declare the TUI unsupported on Windows** — rejected because the pinned terminal runtime implements Windows console input explicitly and the harness has no POSIX-only production dependency. A documentation-only exclusion would discard an existing product path to accommodate a test harness gap. +- **Run the POSIX driver through MSYS, Cygwin, or WSL** — rejected because that would test a compatibility environment rather than the native Windows console path users run. +- **Use `node-pty` on every host** — rejected because the standard POSIX driver already provides the macOS and Linux boundary without another native package path. Platform-specific drivers keep ConPTY limited to the host that requires it while sharing one scenario contract. +- **Rely on renderer unit tests and semantic terminal snapshots** — rejected because fake terminals do not prove Loader boot, real raw input, process exit, or terminal restoration at the operating-system boundary. + +## Consequences + +- The Windows artifact lane executes the startup, scripted interaction, resume-failure, and restoration scenarios, and the suite has no supported-platform skip. +- The Windows process proof depends on ConPTY and a pinned `node-pty` release; changing that dependency or its allowed install script requires native-boundary review. +- The two PTY drivers can differ internally, but shared inputs and assertions keep their observable TUI contract aligned. +- Windows support remains bounded by the Node and pi-tui versions shipped by the repository; unsupported historical Windows console environments do not receive a compatibility layer. diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md new file mode 100644 index 0000000000..eb6ada8cb8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 在 Windows 上支持 TUI + +Status: implemented + +[English](2026-07-20-windows-tui-support.md) | 中文 + +## 问题 + +全屏 TUI 将原始输入、ANSI 渲染、终端尺寸变更事件和终端恢复委托给 pi-tui 的 `ProcessTerminal`。该依赖已实现原生 Windows 控制台路径,但仓库的真实进程冒烟测试此前使用 Python 中仅适用于 POSIX 的 `pty` 和 `termios` 模块。若在 Windows 上跳过该测试,这条受支持的产品路径便会缺少针对启动、输入、交互、失败报告和终端恢复的测试覆盖率。 + +TUI 平台契约必须以交付给用户的运行时为准,而不是取决于某个测试驱动程序的可移植性。只有产品存在不受支持的运行时依赖,或已证实存在语义缺口时,排除某个平台才有依据。 + +## 决策 + +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 在 Windows、macOS 和 Linux 上均支持交互式终端。产品继续使用 pi-tui 的 `ProcessTerminal`;在 Windows 上,它会在进入原始模式后启用虚拟终端输入,并避开仅适用于 Unix 的 `SIGWINCH` 刷新。DeepSeek Harness 不增加平台拒绝逻辑,也不采用功能受限的 Windows 模式。 + +真实 Loader 冒烟测试根据宿主选择原生伪终端边界。macOS 和 Linux 继续使用 Python POSIX PTY 驱动,Windows 则使用 `node-pty` 和 ConPTY。两种驱动接收相同的启动命令、环境、终端尺寸、以标记为触发条件的输入动作、超时、预期退出码和输出断言;3 个冒烟场景都会在每个受支持平台上运行。 + +`node-pty` 是 examples 工作区仅供测试使用的依赖。该依赖经评审的原生安装脚本在 `pnpm-workspace.yaml` 中显式启用;生产 TUI 包(package)不会新增依赖或子进程层。 + +## 曾考虑的替代方案 + +- **声明 TUI 不支持 Windows**:不予采纳,因为固定版本的终端运行时已显式实现 Windows 控制台输入,且 harness 没有仅适用于 POSIX 的生产依赖。仅通过文档排除 Windows,等于为迁就测试 harness 的缺口而舍弃现有产品路径。 +- **通过 MSYS、Cygwin 或 WSL 运行 POSIX 驱动**:不予采纳,因为这会测试兼容环境,而不是用户实际运行的原生 Windows 控制台路径。 +- **在所有宿主上使用 `node-pty`**:不予采纳,因为标准 POSIX 驱动已经为 macOS 和 Linux 提供所需边界,无需增加另一条原生包路径。按平台选择驱动可将 ConPTY 限定在需要它的宿主,同时共享同一份场景契约。 +- **依赖渲染器单元测试和语义终端快照**:不予采纳,因为模拟终端无法证明 Loader 启动、真实原始输入、进程退出或操作系统边界上的终端恢复。 + +## 后果 + +- Windows 产物 lane 执行启动、脚本化交互、配置恢复失败和终端恢复场景,这套测试不会在任何受支持平台上跳过。 +- Windows 进程级验证依赖 ConPTY 和固定版本的 `node-pty`;变更该依赖或允许执行的安装脚本时,必须进行原生边界评审。 +- 两种 PTY 驱动的内部实现可以不同,但共享的输入和断言会使其可观测 TUI 契约保持一致。 +- Windows 支持范围以仓库交付的 Node 和 pi-tui 版本为界;不受支持的旧版 Windows 控制台环境不会获得兼容层。 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index f44644c430..328bc3e55a 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -4,6 +4,8 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. +Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. + This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. From 85c658b0285015e84d8764f6fc51eefb7f6d6fe6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:11:38 +0800 Subject: [PATCH 124/132] fix: drop retry dependency from pruner --- docs/module-graph.md | 21 +++++++++---------- .../compact-tool-result-prune/package.json | 2 -- pnpm-lock.yaml | 3 --- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index b4c7a6217d..fe774feedc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -183,6 +183,8 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web pkg_web_search_deepseek --> pkg_web @@ -209,6 +211,12 @@ flowchart TD pkg_skill_local --> pkg_fs pkg_skill_local --> pkg_home pkg_skill_local --> pkg_skill + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_spill_local --> pkg_spill pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session @@ -255,9 +263,6 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_llm_retry - pkg_compact_tool_result_prune --> pkg_session pkg_permission --> pkg_bash pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy @@ -300,12 +305,6 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_llm @@ -499,6 +498,7 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | @@ -513,6 +513,7 @@ flowchart TD | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -527,14 +528,12 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index a2b27b894e..81c81eb894 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -23,7 +23,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -35,7 +34,6 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 659dcb125b..67dccbb360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,9 +436,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../llm/llm-retry '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 02a61d3a25c32425f30a94f4fc3e6674dd79843b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:16:40 +0800 Subject: [PATCH 125/132] Clarify the platform-specific PTY path --- .../feature/2026-07-20-windows-tui-support.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-20-windows-tui-support.md | 2 +- .../implemented/feature/2026-07-20-windows-tui-support.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml index 34b6fe5c07..4edd7b7223 100644 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-windows-tui-support.md: 1771d0c8f71b5273c333b00025f8b58e0f74a868 -2026-07-20-windows-tui-support.zh.md: eb6ada8cb80c232bb92624893ddd19a575fa4bb7 +2026-07-20-windows-tui-support.md: 6b728486dd50faac067933ce06f883447aae821f +2026-07-20-windows-tui-support.zh.md: 2b53b05ff6231361d79b4304181dc0e6d8e24e68 diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md index 1771d0c8f7..6b728486dd 100644 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md @@ -22,7 +22,7 @@ The real Loader smoke selects a native pseudo-terminal boundary by host. macOS a - **Declare the TUI unsupported on Windows** — rejected because the pinned terminal runtime implements Windows console input explicitly and the harness has no POSIX-only production dependency. A documentation-only exclusion would discard an existing product path to accommodate a test harness gap. - **Run the POSIX driver through MSYS, Cygwin, or WSL** — rejected because that would test a compatibility environment rather than the native Windows console path users run. -- **Use `node-pty` on every host** — rejected because the standard POSIX driver already provides the macOS and Linux boundary without another native package path. Platform-specific drivers keep ConPTY limited to the host that requires it while sharing one scenario contract. +- **Use `node-pty` on every host** — rejected because the established POSIX driver already provides the macOS and Linux boundary; replacing it would widen the runtime change without improving those hosts. Platform-specific drivers reserve the `node-pty` runtime path for Windows while sharing one scenario contract. - **Rely on renderer unit tests and semantic terminal snapshots** — rejected because fake terminals do not prove Loader boot, real raw input, process exit, or terminal restoration at the operating-system boundary. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md index eb6ada8cb8..2b53b05ff6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-windows-tui-support.zh.md @@ -22,7 +22,7 @@ TUI 平台契约必须以交付给用户的运行时为准,而不是取决于 - **声明 TUI 不支持 Windows**:不予采纳,因为固定版本的终端运行时已显式实现 Windows 控制台输入,且 harness 没有仅适用于 POSIX 的生产依赖。仅通过文档排除 Windows,等于为迁就测试 harness 的缺口而舍弃现有产品路径。 - **通过 MSYS、Cygwin 或 WSL 运行 POSIX 驱动**:不予采纳,因为这会测试兼容环境,而不是用户实际运行的原生 Windows 控制台路径。 -- **在所有宿主上使用 `node-pty`**:不予采纳,因为标准 POSIX 驱动已经为 macOS 和 Linux 提供所需边界,无需增加另一条原生包路径。按平台选择驱动可将 ConPTY 限定在需要它的宿主,同时共享同一份场景契约。 +- **在所有宿主上使用 `node-pty`**:不予采纳,因为现有 POSIX 驱动已经为 macOS 和 Linux 提供所需边界;替换该驱动会扩大运行时变更范围,却不会给这两个宿主带来改进。按平台选择驱动,仅在 Windows 上启用 `node-pty` 运行时路径,同时共享同一份场景契约。 - **依赖渲染器单元测试和语义终端快照**:不予采纳,因为模拟终端无法证明 Loader 启动、真实原始输入、进程退出或操作系统边界上的终端恢复。 ## 后果 From f6dfc5d3c3df5c643578e665d7f86de33ad61ce5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:23:37 +0800 Subject: [PATCH 126/132] docs: retire stale stdio package links --- .../simplification/2026-07-04-fold-stdio-ui-helper.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index c44201b4e6..b05dd22357 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -12,9 +12,9 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +At the time, the helper moved into `@deepseek-ai/dsh-stdio` as the terminal-channel plugin. `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, keeping EOF handling, rendering, disposal, and piped-vs-TTY behavior under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape consumed by the app mount, while the then-current Echo and REPL Loader smokes proved the composed tree and the plugin-shape suite pinned explicit `unwrapExports` behavior. The superseding removal note above owns the current package and example state. -The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. +The earlier support helper package was removed: its manifest, tsconfig references, module-graph rows, and README rows disappeared, while the remaining documentation described the in-package module. ## Alternatives considered From cc2e14f76e1c3919e4bb0be27c7785ca978c4ff4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:11:26 +0800 Subject: [PATCH 127/132] fix: close recovery review gaps --- docs/core-data-structures/session.md | 2 +- packages/core/agent-loop/src/loop.ts | 10 +++++-- .../agent-loop/tests/request-recovery.spec.ts | 26 +++++++++++++++++++ packages/core/session/README.md | 2 +- packages/llm/llm-pi-ai/src/stream.ts | 5 +++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 13 ++++++++++ packages/llm/llm/src/error.ts | 1 + packages/llm/llm/tests/service.spec.ts | 1 + packages/ui/acp/src/index.ts | 7 ++--- packages/ui/acp/tests/stream-update.spec.ts | 6 ++++- 10 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index c73b750b4b..8650688222 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -430,7 +430,7 @@ declare class Session { - `context/message` → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. - `steering/message` → a user-role message carrying its content verbatim at its chronological position. -Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. +Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. An operational error's step number is on `turn/end.reason` for `kind: 'error'`, with normalized `LlmFailure` facts for a final model-request failure and message/code for other live errors. Because this unreleased format intentionally has no compatibility promise, seed/load validation rejects request headers without provider+model and assistant messages without provider/model provenance instead of guessing a route for historical data. ## Live-session fork API diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b67a8f9712..97a32e2f38 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -32,7 +32,7 @@ class TerminalModelRequestFailure extends Error { readonly requestError: RequestError, readonly failure: LlmFailure, ) { - super(requestError.message, { cause: requestError }) + super(failure.message, { cause: requestError }) this.name = 'TerminalModelRequestFailure' } } @@ -69,6 +69,12 @@ function errorData(err: RequestError): { message: string; code?: string } { return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} } } +/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */ +function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure { + const message = errorChain(err) + return { ...failure, message: message === '' ? failure.message : message } +} + /** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { @@ -231,7 +237,7 @@ async function runTurn( errorReported = true reason = failure === undefined ? { kind: 'error', step, ...errorData(err) } - : { kind: 'error', step, failure: { ...failure, message: errorChain(err) } } + : { kind: 'error', step, failure: durableFailure(err, failure) } try { events.emit('agent/error', turn, step, err) } catch { diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index ab82cf182e..cf87d376ef 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, + HarnessError, LlmAdapter, LlmError, ProviderRequestId, @@ -419,6 +420,31 @@ describe('agent post-step and request-error lifecycle', () => { expect(seen).toBe(original) }) + it('keeps an adapter error with a hostile message accessor on the recovery path', async () => { + const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', { + get() { throw new Error('SDK message accessor trap') }, + }) + const ctx = await harness(new SynchronousDispatchFailureAdapter(original)) + const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' }) + let seenError: Error | undefined + let seenFailure: LlmFailure | undefined + ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => { + seenError = error + seenFailure = failure + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seenError).toBe(original) + expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' }) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } }, + }) + }) + it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => { const original = new LlmError('provider busy', 'RATE_LIMIT', { cause: new Error('upstream connection reset'), diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 79e657e3ca..28210e8f6c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -60,7 +60,7 @@ Durable values need one accepted representation, not a check followed by a secon ### Session event vocabulary (`types.ts`) -The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. +The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure. Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 2c89d1e224..37736af716 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -35,7 +35,10 @@ function classifyPiAiError(message: string): string { if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST' if (/\b5\d\d\b/.test(message)) return 'SERVER' if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT' - if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)) return 'TRANSPORT' + if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) + || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) { + return 'TRANSPORT' + } return 'PI_AI_ERROR' } diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index e33f0bf09a..15471875d2 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -535,6 +535,10 @@ describe('mapStopReason / mapUsage', () => { .toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: insufficient_quota' }))) .toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.', + }))).toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' }))) .toMatchObject({ kind: 'error', failure: { code: 'SERVER' } }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'provider timed out' }))) @@ -555,6 +559,15 @@ describe('mapStopReason / mapUsage', () => { }))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } }) }) + it.each([ + 'other side closed', + 'HTTP2 request did not get a response', + 'WebSocket closed unexpectedly', + ])('maps pi-ai transport wording %j', (errorMessage) => { + expect(mapStopReason(assistant({ stopReason: 'error', errorMessage }))) + .toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } }) + }) + it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => { expect(mapStopReason(assistant({ stopReason: 'error', diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 8752f30b2f..758e062895 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -74,6 +74,7 @@ export function isContextWindowExceededError(detail: string): boolean { export function isQuotaExceededError(detail: string): boolean { return /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i.test(detail) || /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i.test(detail) + || /\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i.test(detail) || /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted)\b/i.test(detail) || /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail) } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index e50a5ce615..9f90a2b7cd 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -90,6 +90,7 @@ describe('LlmService', () => { 'account balance depleted', 'usage-limit-exceeded', 'out of credits', + 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.', ]) expect(isQuotaExceededError(detail)).toBe(true) expect(isQuotaExceededError('HTTP 429: rate limit reached')).toBe(false) expect(isQuotaExceededError('quota resets in one minute')).toBe(false) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 60294ddacb..38eeb3f914 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1124,11 +1124,8 @@ export function streamSessionEventUpdate( return } case 'turn/end': { - if (event.data.reason.kind !== 'error') return - const message = 'failure' in event.data.reason - ? event.data.reason.failure.message - : event.data.reason.message - const text = `\n\n[Model attempt failed; any partial output above is discarded: ${message}]\n\n` + if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return + const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n` notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index ad71b1135e..feda449054 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -65,7 +65,7 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) - it('marks retry and terminal failure boundaries in the append-only update stream', () => { + it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => { expect(updatesFor(evt('llm/retry', { turn: 1, step: 1, @@ -90,6 +90,10 @@ describe('streamSessionEventUpdate', () => { text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n', }, }]) + expect(updatesFor(evt('turn/end', { + turn: 1, + reason: { kind: 'error', step: 2, message: 'post-step failed' }, + }))).toEqual([]) }) it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { From 7094608c5049ac3f1c96b67e3afd1dec04b738d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:30:52 +0800 Subject: [PATCH 128/132] Update goal RFC rollup for current app surfaces --- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-16-harness-level-loop.md | 6 +++--- .../implemented/feature/2026-07-16-harness-level-loop.zh.md | 6 +++--- AGENTS.md | 2 +- docs/testing.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 19c946a6f6..99565bc83c 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: f9f501d365d368cffdbc0f909db48f9d6ce926b5 -2026-07-16-harness-level-loop.zh.md: 1d09841e74530fbe4c9d86b7a481a95494890afe +2026-07-16-harness-level-loop.md: 1b99dc8720239dea5cfaf5711595ac0f9962ab46 +2026-07-16-harness-level-loop.zh.md: 33db26f63b733046b29eb36ccfbe068b1cf01124 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index f9f501d365..1b99dc8720 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Direct-human provenance is enforced in code; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. Line-oriented stdio does not consume the command plane; its ordinary human text can still authorize model goal tools when that stack is mounted. +TUI and ACP mount the shared command registry and complete goal stack by default and expose `/goal` through one producer. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. ### Fresh-agent Ralph execution @@ -94,7 +94,7 @@ External products are comparators, not compatibility targets. The local source s ### Verification -The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package or echo-agent coverage. +The six owning Agent Notes record unit, integration, process, snapshot, cancellation, replay, and built-runtime coverage. The stack exercises strict goal-record folding, compare-and-set races, session fork inheritance, disarmed restoration, natural-language direct-human authority, configurable caps and blocked thresholds, exact goal-round attribution, adapter-wide command discovery, and transcript isolation. Shipped keyless snapshots cover model goal creation/inspection through the headless app, multi-round same-session lifecycle and cancellation through ACP, direct `/goal` status without a model turn, and two real Ralph rounds through the headless app. The Ralph snapshot boots the worker-thread engine, spawn provider, structured-output runtime, and agent loop, then inspects distinct unseeded child logs and exact one-way bounded handoff while pinning the parent stream. Focused real-stack tests additionally cover completion, blocker and round-limit outcomes, malformed and oversized reports, ordinary child failure with the last good handoff, one phase event, and cancellation to child quiescence. Package sources remain under the repository's per-file 100% coverage gate, and built-binary tests cover installed-artifact resolution. The implementation experience is recorded in the root testing policy: every non-trivial model- or human-visible change must carry a real-example keyless snapshot in the same PR rather than relying on package-only or mock-only fixture coverage. ## Alternatives considered @@ -126,4 +126,4 @@ The six owning Agent Notes record unit, integration, process, snapshot, cancella - **No goal reflector** — concern events, automatic no-progress heuristics, goal revision by an independent reflector, stuck-pattern detection, and `loop_split` are not implemented. Humans can edit, pause, clear, or resume the goal directly. - **Ralph policy remains narrow** — one round creates one fresh child; within-round fan-out, evaluator/worker role separation, dynamic provider/model selection, and structural recursive-Ralph tool denial need separate policy surfaces. Prompt guidance is not enforcement. - **Ralph does not retry a failed child** — an ordinary failure preserves the failed round and last good handoff, while fatal workflow infrastructure failures can end before that state is available. Retry count, backoff, and richer failure transport need separate policy and seam design. -- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in stdio/JSON-RPC. +- **Portable UI remains modest** — TUI and ACP render plain-text goal status and generic Ralph cards. There is no continuous status widget, reconnectable command output, modal goal editor, or command plane in the headless CLI or JSON-RPC front doors. diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index 1d09841e74..33db26f63b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -70,7 +70,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。直接人类来源由代码强制执行;语义解释仍是模型判断。自治目标 Round 可以为准确当前目标 Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。行式 stdio 不消费命令平面;挂载目标栈后,它的普通人类文本仍可授权模型目标工具。 +TUI 与 ACP 默认挂载共享命令注册表和完整目标栈,并通过同一个生产方暴露 `/goal`。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI agent spine 要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI 与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 @@ -94,7 +94,7 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 ### 验证 -六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖包级或 echo-agent 覆盖。 +六份所属 Agent Note 记录了单元、集成、进程、快照、取消、重放与构建后运行时覆盖。该栈验证严格目标记录折叠、比较并交换竞争、会话 fork 继承、恢复后未激活、自然语言直接人类权限、可配置上限与阻塞阈值、准确目标回合归属、适配器范围的命令发现与转录隔离。已发布的无密钥快照覆盖通过无头应用创建/检查模型目标、通过 ACP 执行多 Round 同会话生命周期与取消、无需模型 Turn 的直接 `/goal` 状态,以及通过无头应用执行两个真实 Ralph Round。Ralph 快照会启动工作线程引擎、spawn provider、结构化输出运行时与 agent loop,随后检查互不相同且无种子的子日志和准确单向有界交接,同时固定父级事件流。聚焦的真实栈测试还覆盖完成、阻塞与 Round 上限结果、畸形及过大报告、保留上一份有效交接的普通子 agent 失败、单个阶段事件,以及取消后达到子 agent 静止状态。包源码继续受仓库逐文件 100% 覆盖率门禁约束,构建后二进制测试覆盖已安装产物解析。实现经验已记录进根测试策略:每项非平凡的模型或人类可见变更都必须在同一 PR 中携带真实示例无密钥快照,而不能依赖仅包级或仅模拟夹具的覆盖。 ## 考虑过的替代方案 @@ -126,4 +126,4 @@ Codex 提供了这里采用的最小可观察目标 UX:一个附着于聊天 - **没有目标反思器**——concern 事件、自动无进展启发式、由独立反思器执行的目标修订、卡住模式检测与 `loop_split` 均未实现。人类可以直接编辑、暂停、清除或恢复目标。 - **Ralph 策略仍然狭窄**——一个 Round 创建一个全新子 agent;Round 内扇出、评估器/工作者角色分离、动态 provider/模型选择与结构化递归 Ralph 工具禁止都需要独立策略表面。提示词指导不是强制执行。 - **Ralph 不会重试失败的子 agent**——普通失败会保留失败 Round 与上一份有效交接,而致命工作流基础设施错误可能在该状态可用前结束。重试次数、退避与更丰富的失败传输需要独立的策略与接缝设计。 -- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,也没有 stdio/JSON-RPC 命令平面。 +- **可移植 UI 仍较朴素**——TUI 与 ACP 渲染纯文本目标状态和通用 Ralph 卡片。系统没有持续状态组件、可重连命令输出、模态目标编辑器,无头 CLI 与 JSON-RPC 前端也没有命令平面。 diff --git a/AGENTS.md b/AGENTS.md index 5f1ee04096..6a0e4ca13b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and echo-agent fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). diff --git a/docs/testing.md b/docs/testing.md index 4a112c2d6e..9ac79922c8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or echo-agent compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation. +Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or mock-only compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation. From de72f972b71c58629fff7844986bbdedd971e860 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:58:01 +0800 Subject: [PATCH 129/132] Exercise normalized goal-round rate limits --- packages/goal/goal-session/tests/goal-session.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 1d41514a3c..8495b73ecc 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -6,7 +6,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalView } from '@deepseek-ai/dsh-goal' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { TurnEndReason } from '@deepseek-ai/dsh-session' @@ -232,7 +232,7 @@ describe('same-session goal driving', () => { }) it.each([ - ['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'], + ['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'], ['request error', new Error('provider broke'), 'turn-error'], ['max tokens', maxTokensResponse('unfinished'), 'max-tokens'], ] as const)('stops after a %s without an automatic retry', async (_label, response, code) => { From 578df3f986f6af1a91c74d264419e2391509654f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:20:44 +0800 Subject: [PATCH 130/132] Clarify goal rounds around request recovery --- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-16-harness-level-loop.md | 2 +- .../implemented/feature/2026-07-16-harness-level-loop.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index 99565bc83c..1a8c03cb52 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-harness-level-loop.md: 1b99dc8720239dea5cfaf5711595ac0f9962ab46 -2026-07-16-harness-level-loop.zh.md: 33db26f63b733046b29eb36ccfbe068b1cf01124 +2026-07-16-harness-level-loop.md: 9a9511b9dcea1b5fdc90f4fc716c4399f2346967 +2026-07-16-harness-level-loop.zh.md: 284e73051eaaa4633b9f56367de9096dadc8184e diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index 1b99dc8720..9a9511b9dc 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -62,7 +62,7 @@ The goal-round driver owns at most one pending reservation per exact live agent. Only the durable goal-sourced `user/message` charges a round. Stale reservations become rejected zero-step turns without consuming the cap. A concurrent goal revision wins over settlement from an older round. -Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. The driver never invents an automatic retry after an abnormal outcome. A human can later authorize resume through ordinary language or `/goal resume`. +Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later authorize resume through ordinary language or `/goal resume`. ### Human and model surfaces diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index 33db26f63b..284e73051e 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -62,7 +62,7 @@ fork 会话会继承持久目标前缀,因为这是自然的重放结果。for 只有持久的目标来源 `user/message` 会计入一个 Round。过时预留会成为未消耗上限的零 Step 拒绝 Turn。并发目标修订会胜过旧 Round 的结算。 -普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。驱动器绝不会在异常结果后凭空发起自动重试。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 +普通 Turn 完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;Turn 错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个 Turn 内重试暂时性 provider 失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过普通语言或 `/goal resume` 授权恢复。 ### 人类与模型表面 From b850e69068fd14e8352d4614281c7aff7640257e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:24:43 +0800 Subject: [PATCH 131/132] Condense snapshot testing policy --- docs/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 9ddf7930e3..85601ab6c9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -37,4 +37,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model- or human-visible change adds or updates a keyless scenario through a real runnable example in the owning snapshot suite in the same PR. Package tests, e2e-only assertions, test-only or mock-only compositions, and a PR rationale do not substitute for the assembled application transcript; when the current harness cannot express the behavior, extending that harness is part of the change. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it before implementation. +Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. From 2aae64b5da49b62dcb101b0852ad52ed2aab241d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:35:49 +0800 Subject: [PATCH 132/132] fix(goal-session): preserve typed error variants --- packages/goal/goal-session/src/outcome.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts index 1dc73cbe1a..e69f53eeec 100644 --- a/packages/goal/goal-session/src/outcome.ts +++ b/packages/goal/goal-session/src/outcome.ts @@ -28,8 +28,7 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal case 'aborted': return { kind: 'pause', reason: reason.reason ?? 'cancelled' } case 'error': { - const code = reason.failure?.code ?? reason.code - const message = reason.failure?.message ?? reason.message ?? 'model request failed' + const { code, message } = reason.failure ?? reason return code === 'RATE_LIMIT' || code === 'QUOTA' ? { kind: 'blocked', code: 'usage-limited', message } : { kind: 'blocked', code: 'turn-error', message }