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 01/88] 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 02/88] 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 03/88] 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 04/88] 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 05/88] 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 06/88] 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 89f9e4fc2109d7163461f0ea0f6ed771809dd9e1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 16:17:36 +0800 Subject: [PATCH 07/88] feat(fs-search): hide grep glob without rg --- docs/config-catalog.md | 2 +- ...6-07-09-bash-backed-grep-glob-discovery.md | 20 ++--- docs/tool-catalog.md | 4 +- examples/coding-agent/cordis.yml | 7 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 13 ++++ packages/fs/README.md | 4 +- packages/fs/tool-fs-search/README.md | 12 +-- packages/fs/tool-fs-search/src/index.ts | 52 +++++++++++-- .../fs/tool-fs-search/tests/tools.spec.ts | 78 ++++++++++++++++--- scripts/gen-tool-catalog.ts | 72 +++++++++++++++-- 10 files changed, 218 insertions(+), 46 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 53db69125c..5ee653103e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -871,7 +871,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:59`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index bc8a452c91..4644f3e07a 100644 --- a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -10,13 +10,13 @@ Search output also has two distinct budgets. The tool needs enough raw `rg` outp ## Decision -`glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. The package registers model-facing filesystem discovery tools, but execution uses `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations. +`glob` and `grep` are conditional model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, backed by the bash seam, not by new `ctx.fs` provider methods. At plugin load, the package checks `command -v rg >/dev/null 2>&1` through `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)`; if the command exits nonzero, the package logs a warning and registers neither tools nor prompt sections. A probe that cannot start, times out, aborts, is killed, or produces no exit code fails plugin load loudly because that is a broken bash executor rather than an absent optional binary. When registered, execution uses the same `ctx.bash.resolve(request)` followed by `ctx.bash.run(spec)` flow with fixed `rg` command templates assembled by the tool. The tool layer owns schemas, argument validation, shell quoting, result parsing, result formatting, retention, formatted-result spill handoff, and timeout declaration. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution across local, sandboxed, or remote bash implementations. The tools do not use `ctx.bash.start()` and do not create model-visible background tasks. They run as ordinary foreground tools from the agent loop's perspective: the tool call returns only after the `rg` command exits, times out, is aborted, or fails. `defineTool({ timeoutMs })` declares the cooperative tool-call budget, `@deepseek-ai/dsh-timeout-policy` enforces it through `exec.signal`, and the tool forwards that signal into the bash request before `resolve()` / `run()`. The bash backend's own timeout remains a second safety cap; whichever aborts first wins. The tools align `path` with Claude Code's search tools while binding resolution to the bash workdir, not to `ctx.fs`. The tool derives the bash request workdir from `exec.agent?.session.header.cwd`, mirroring `dsh-tool-bash` and `dsh-tool-fs`; when no session cwd exists, it omits `request.workdir` so the bash implementation applies its configured cwd or process cwd through `resolve()`. For `grep`, `path` is an optional ripgrep target and may be a file or directory; omitted means the resolved bash workdir. For `glob`, `path` is an optional directory search root; omitted means the resolved bash workdir. Relative `path` values resolve against that workdir. Returned paths are displayed relative to the resolved bash workdir when possible and are intended to be follow-up-readable only in co-located deployments where the bash workdir and filesystem `read` root are the same workspace. v1 documents that deployment requirement but does not perform runtime cross-service validation. Remote or virtual filesystem search is deferred until there is a shared workspace/root contract or a provider-specific search backend. -The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash. +The package does not inject `fs`. It injects `tools`, `systemPrompt`, and `bash`; it deliberately reads `spillStore` with `ctx.get('spillStore')` instead of static inject because formatted-result spill is optional. Existing `@deepseek-ai/dsh-tool-fs` deployments that only want `read` / `write` / `edit` do not need to load bash. Deployments that load search need `rg` available in the bash executor environment for the tools to enter the model-visible schema. ### Package shape @@ -79,9 +79,9 @@ The `path` field follows the same split as Claude Code: `grep.path` is a file-or Raw `rg` stdout is an internal transport detail. The tool requests `stdoutMaxBytes: rawOutputMaxBytes` through `ctx.bash.resolve()` and parses `stdout.text` only when the executor returns untruncated stdout within that cap. If stdout is larger than `rawOutputMaxBytes`, or the executor still returns `stdout.truncated`, the tool fails with a clear search error telling the model to narrow `pattern`, `path`, or `include`. The tool never exposes raw `rg` output or bash raw spill paths to the model. -Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, missing `rg`, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`. +Only stdout is a parse source. Stderr is diagnostic text for invalid patterns, runtime `rg` disappearance after registration, and search failures; if bash truncates stderr, the tool uses the retained stderr tail with a truncation note and does not read `stderr.spillPath`. -If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / missing `rg` / inaccessible search workdir are failures. +If `ctx.bash.run()` reports `aborted` because the tool timeout or caller cancellation fired, the tool returns a structured failure rather than pretending there were no matches. If bash reports its own timeout first, the tool likewise fails with a clear timeout message. Nonzero ripgrep exit semantics are tool-owned: exit 0 is success with matches, exit 1 is success with no matches, invalid pattern / runtime `rg` disappearance / inaccessible search workdir are failures. Search failures use a package-owned `HarnessError` subclass with `SEARCH_*` codes, not `FsErrorCode`, because these tools are not `ctx.fs` provider operations. The v1 vocabulary is `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, and `SEARCH_ABORTED`. Model argument validation failures such as missing required fields, blank strings, or unsupported negated/list `include` values remain ordinary tool argument errors. @@ -116,7 +116,7 @@ Line 12: ... (Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.) ``` -If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, missing `rg`, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields. +If the complete logical result fits under the inline cap, no formatted spill artifact is created. If the complete logical result is too large but formatted spill is unavailable, the footer says that the result was capped and the complete result could not be saved. The `truncated` / omitted count is a budget fact, not an incomplete-search fact; timeout, invalid regex, runtime `rg` disappearance, inaccessible workdirs, raw-output overflow, binary skips, and parse failures stay in tool-domain error or incomplete fields. ## Alternatives considered @@ -138,22 +138,24 @@ If the complete logical result fits under the inline cap, no formatted spill art **Expand the bash seam with a raw-output reader first.** Rejected: a portable `readRawOutput(ref, maxBytes)` API would add reference lifetime, permission, and backend storage semantics. A per-run `stdoutMaxBytes` request is the narrower seam: search either receives complete stdout within `rawOutputMaxBytes` or fails clearly. +**Always register and report missing `rg` only at execution time.** Rejected: a model-visible tool schema is a promise that the deployment can attempt that capability. If the bash executor cannot find ripgrep at load, the safer surface is no `glob` / `grep` tools or prompt guidance. Execution-time missing-`rg` classification remains as a defensive fallback for environments that change after registration. + ## Testing -- Tests prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant. +- Tests cover registration-time `rg` probing (probe success registers both tools and prompt sections, nonzero probe skips both tools and prompt sections with a warning, infrastructure probe failures reject plugin load), prove an aborted `exec.signal` reaches the bash backend (same-reference spec assertion plus the `SEARCH_ABORTED` result), and cover command construction/quoting (malicious patterns, paths with spaces, leading-dash values, quotes, newlines, glob metacharacters — unit assertions plus a real `bash -c` round-trip for every hostile value), `grep.path` as file and directory targets, `glob.path` as a directory search root, invalid pattern handling, no matches, malformed `rg --json` output, matched-line preview truncation, raw-output overflow, timeout/abort, formatted spill success/failure, the package-owned `SEARCH_*` error codes, and the no-background-task invariant. - The first-party tool-owned spill precedent is covered directly: spill backend present, spill backend absent, `saveText()` failure, and missing spill owner. - The package has real Loader-path coverage for the namespace plugin export shape (`name`, `inject`, `Config`, and `apply`, with no default export). -- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite alone carries the per-file 100% coverage gate. +- A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on the test process PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite carries registration and execution coverage for missing `rg`, plus the per-file 100% coverage gate. - Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every golden would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session. ## Consequences -- `glob` and `grep` are model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. The package injects `tools`, `systemPrompt`, and `bash`; it does not inject `fs`, and `ctx.spillStore` stays optional via `ctx.get('spillStore')`. +- `glob` and `grep` are conditional model-facing tools in `@deepseek-ai/dsh-tool-fs-search`, not `ctx.fs` provider methods and not part of the existing `@deepseek-ai/dsh-tool-fs` root plugin. They register only when the bash executor can find `rg`; the package injects `tools`, `systemPrompt`, and `bash`, does not inject `fs`, and keeps `ctx.spillStore` optional via `ctx.get('spillStore')`. - The schemas are exactly `glob(pattern, path?)` and `grep(pattern, path?, include?)`; search caps and timeout are defaulted, validated Config fields (`globMaxResults`, `grepMaxMatches`, `grepMaxLineBytes`, `rawOutputMaxBytes`, `timeoutMs`). - 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 coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-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/docs/tool-catalog.md b/docs/tool-catalog.md index 382eb04c4c..5b6e281b1a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@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; the request-header ToolsDelta 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-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/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@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. | @@ -428,7 +428,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts) -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. +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` diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index b23d785439..698ae97403 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -133,9 +133,10 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' -# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the -# local bash executor above — not ctx.fs. Capped results save the complete -# formatted list through the spill backend below (ctx.spillStore, optional). +# 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' diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 43490b99dc..b77cbdfd8d 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -61,6 +61,19 @@ describe('gen-tool-catalog collectToolCatalog', () => { expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts') }) + it('harvests search tools without depending on the generator process PATH', async () => { + const oldPath = process.env.PATH + try { + process.env.PATH = '' + const catalog = await collectToolCatalog() + const search = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-fs-search') + expect(search?.schemas.map(s => s.name).sort()).toEqual(['glob', 'grep']) + } finally { + if (oldPath === undefined) delete process.env.PATH + else process.env.PATH = oldPath + } + }) + it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => { // `tool-subagent`'s registered name is the load-time `toolName` config, so // the shipped agents surface this one package as both `subagent` and diff --git a/packages/fs/README.md b/packages/fs/README.md index 039cb39ae9..ba57eede25 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -8,9 +8,9 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-local/` | Local-filesystem `FileSystem` implementation | (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-search/` | Model-facing `glob`/`grep` discovery tools, backed by fixed ripgrep commands through the bash seam (`ctx.bash`), NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | +| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (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. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). +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. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). ## No timeouts on file IO diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 2f9df200de..81e0042e92 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -1,20 +1,20 @@ # @deepseek-ai/dsh-tool-fs-search -The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools** — `glob`, `grep` — backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a bounded, workdir-relative result. The package injects `tools`, `systemPrompt`, and `bash` — deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check -// Default deployment: a bash executor, then the discovery tools. +// Default deployment: a bash executor whose PATH includes rg, then the discovery tools. await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local -await ctx.plugin(ToolFsSearch) // this package — registers glob/grep +await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep // Optional: a spill backend makes capped results fully recoverable. await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails. -## Deployment requirement: co-located bash + filesystem +## Deployment requirement: rg + co-located bash/filesystem -Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. +The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. ## Config @@ -43,4 +43,4 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax ## Errors -Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (missing `rg`, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. +Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors. diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 8c33d5770a..5930890b7a 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -1,6 +1,7 @@ /** * The model-facing filesystem discovery tool suite (`glob`, `grep`) over the - * bash executor seam (`ctx.bash`). This single plugin registers both tools. + * bash executor seam (`ctx.bash`). This single plugin registers both tools + * only when the mounted bash executor can find `rg` on its `PATH`. * * ## Bash-backed, not a `ctx.fs` provider method * @@ -12,9 +13,11 @@ * parsing, retention, formatted-result spill, and timeout declaration; the * bash executor owns request defaulting/capping, subprocess execution, * process-group termination, environment scrubbing, raw output capture, and - * backend substitution. The package injects `tools`, `systemPrompt`, and - * `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read opportunistically - * with `ctx.get()` because formatted-result spill is optional. + * backend substitution. At load, the package probes `command -v rg` through the + * same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt + * sections are not registered. The package injects `tools`, `systemPrompt`, + * and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read + * opportunistically with `ctx.get()` because formatted-result spill is optional. * * Returned paths are displayed relative to the resolved bash workdir and are * follow-up-readable only in co-located deployments where the bash workdir and @@ -80,6 +83,9 @@ export const Config: z = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required +/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */ +const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + /** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { @@ -87,8 +93,38 @@ function assertPositiveInteger(name: string, value: number): void { } } -/** Register the `glob`/`grep` filesystem discovery tool suite. */ -export function apply(ctx: Context, config: Config): void { +/** + * Check whether the mounted bash executor can find `rg`. + * + * Nonzero exit means "not available" and disables this optional tool suite. + * Infrastructure failures stay loud: a deployment with a broken bash executor + * should not silently lose tools in a way that looks like a deliberate skip. + * + * @param ctx - plugin context whose `bash` service is the executor the tools will use. + * @returns true when `command -v rg` exits 0, false when it exits nonzero. + */ +async function ripgrepAvailable(ctx: Context): Promise { + const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND }) + let result + try { + result = await ctx.bash.run(spec) + } catch (error: unknown) { + throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error }) + } + if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) { + throw new Error('tool-fs-search: ripgrep availability probe did not complete') + } + return result.exitCode === 0 +} + +/** + * Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists. + * + * @param ctx - plugin context; registrations are effects scoped to this plugin. + * @param config - resolved plugin configuration from schemastery. + * @returns when ripgrep is unavailable, resolves without registering any tools. + */ +export async function apply(ctx: Context, config: Config): Promise { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig assertPositiveInteger('globMaxResults', resolved.globMaxResults) @@ -96,6 +132,10 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) + if (!await ripgrepAvailable(ctx)) { + ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered') + return + } applyGlobTool(ctx, { maxResults: resolved.globMaxResults, rawOutputMaxBytes: resolved.rawOutputMaxBytes, diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 9131940de5..648eb19585 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -2,12 +2,12 @@ * Consumer-surface tests for the search tools over a FAKE bash executor and a * FAKE spill backend, exercised through `ctx.tools.execute()` so nothing * bypasses the tool registry. The fake executor makes every seam outcome - * scriptable — truncated stdout with/without a raw spill path, abort/timeout, - * signal kills, ripgrep exit codes — so these tests verify schemas, argument - * validation, shell-safe command construction, workdir derivation, signal - * forwarding, `SEARCH_*` error classification, retention, formatted-result - * spill handoff, and the no-background-task invariant. Real-`rg` behavior is - * pinned separately in integration.spec.ts. + * scriptable — registration-time `rg` probing, truncated stdout with/without a + * raw spill path, abort/timeout, signal kills, ripgrep exit codes — so these + * tests verify schemas, argument validation, shell-safe command construction, + * workdir derivation, signal forwarding, `SEARCH_*` error classification, + * retention, formatted-result spill handoff, and the no-background-task + * invariant. Real-`rg` behavior is pinned separately in integration.spec.ts. */ import { describe, expect, it } from 'vitest' @@ -31,6 +31,8 @@ import { toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' +const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + /** A successful run result over the given stdout; overrides script the failure shapes. */ function runResult(stdout: string, overrides?: Partial): BashRunResult { return { @@ -52,13 +54,18 @@ function runResult(stdout: string, overrides?: Partial): BashRunR * create a background task. */ class FakeBash extends BashExecutor { + probeRequests: BashExecRequest[] = [] + probeSpecs: BashExecSpec[] = [] requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 + probeResult: BashRunResult = runResult('') + probeError?: Error handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') override resolve(request: BashExecRequest): BashExecSpec { - this.requests.push(request) + if (request.command === RG_PROBE_COMMAND) this.probeRequests.push(request) + else this.requests.push(request) return { command: request.command, workdir: request.workdir ?? '/work', @@ -69,9 +76,14 @@ class FakeBash extends BashExecutor { sandboxMode: request.sandboxMode, } } - override run(spec: BashExecSpec): Promise { + override async run(spec: BashExecSpec): Promise { + if (spec.command === RG_PROBE_COMMAND) { + this.probeSpecs.push(spec) + if (this.probeError) throw this.probeError + return this.probeResult + } this.specs.push(spec) - return Promise.resolve(this.handler(spec)) + return this.handler(spec) } override start(): BashTask { this.startCalls++ @@ -113,18 +125,36 @@ class FakeSpill extends SpillStore { interface SetupOptions { config?: ToolFsSearch.Config spill?: boolean + probeError?: Error + probeResult?: BashRunResult } async function setup(options: SetupOptions = {}) { const ctx = new Context() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeBash) + const bash = ctx.bash as FakeBash + if (options.probeResult) bash.probeResult = options.probeResult + if (options.probeError) bash.probeError = options.probeError if (options.spill === true) await ctx.plugin(FakeSpill) const fiber = await ctx.plugin(ToolFsSearch, options.config) - const bash = ctx.bash as FakeBash const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined - return { ctx, bash, spill, fiber } + return { ctx, bash, spill, fiber, warnings } +} + +/** Assert plugin setup rejects without letting Vitest pretty-print a live Context on failure. */ +async function expectSetupRejects(options: SetupOptions, message: RegExp): Promise { + let thrown: string | undefined + try { + const loaded = await setup(options) + await loaded.fiber.dispose() + } catch (error: unknown) { + thrown = error instanceof Error ? error.message : String(error) + } + expect(thrown).toMatch(message) } /** A stand-in agent whose session header carries the given cwd (and a stable id). */ @@ -152,13 +182,37 @@ function matchLine(path: string, lineNumber: number, lineText: string): string { describe('registration', () => { it('registers glob and grep with their prompt sections', async () => { - const { ctx } = await setup() + const { ctx, bash } = await setup() + expect(bash.probeRequests).toHaveLength(1) + expect(bash.probeRequests[0]?.command).toBe(RG_PROBE_COMMAND) + expect(bash.probeRequests[0]).not.toHaveProperty('workdir') expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['glob', 'grep']) const prompt = renderPrompt(await ctx.systemPrompt.assemble()) expect(prompt).toContain('Use the glob tool') expect(prompt).toContain('Use the grep tool') }) + it('does not register glob or grep when the bash executor cannot find rg', async () => { + const { ctx, warnings } = await setup({ probeResult: runResult('', { exitCode: 1 }) }) + expect(ctx.tools.schemas()).toHaveLength(0) + const sections = (await ctx.systemPrompt.assemble()).sections.map(s => s.name) + expect(sections).not.toContain('tool:glob') + expect(sections).not.toContain('tool:grep') + expect(warnings).toEqual([ + 'tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered', + ]) + }) + + it('rejects plugin load when the rg availability probe cannot run', async () => { + await expectSetupRejects({ probeError: new Error('spawn bash ENOENT') }, /spawn bash ENOENT/) + }) + + it('rejects plugin load when the rg availability probe is aborted or killed', async () => { + await expectSetupRejects({ + probeResult: runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' }), + }, /tool-fs-search: ripgrep availability probe did not complete/) + }) + it('stays pending until ctx.bash exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index e317f2be24..91ac3adfd1 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -39,6 +39,8 @@ import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -63,6 +65,65 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +const CATALOG_RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + +/** + * Minimal bash service for harvesting `dsh-tool-fs-search` schemas. The search + * plugin now probes `rg` at registration time, but the generated catalog must + * remain independent of the host PATH and never execute a real search. + */ +class CatalogSearchBashExecutor extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? root, + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + owner: request.owner, + sandboxMode: request.sandboxMode, + } + } + + override run(spec: BashExecSpec): Promise { + if (spec.command !== CATALOG_RG_PROBE_COMMAND) { + throw new Error(`gen-tool-catalog: unexpected search bash command during schema harvest: ${spec.command}`) + } + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: spec.timeoutMs, + stdout: { text: '', truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashTask { + throw new Error('gen-tool-catalog: search schema harvest must not start bash tasks') + } + + override get(): BashTask | undefined { + return undefined + } + + override ownerOf(): OwnerToken | undefined { + return undefined + } + + override list(): BashTask[] { + return [] + } + + override readOutput(id: BashTaskId): BashTaskRead { + throw new Error(`gen-tool-catalog: unknown bash task ${id}`) + } + + override kill(id: BashTaskId): boolean { + throw new Error(`gen-tool-catalog: unknown bash task ${id}`) + } +} /** * One tool-plugin package to boot. `mount` is a per-entry recipe (async): it @@ -192,14 +253,15 @@ const TOOL_PACKAGES: ToolPackage[] = [ writes: ['tool/call', 'tool/result'], async mount(ctx) { // The tools inject `bash` (search executes fixed `rg` commands through - // the executor seam, not ctx.fs); boot the local executor to satisfy it. - // `ctx.spillStore` is optional (read via ctx.get) and does not affect the - // schemas, so no spill backend is mounted. - await ctx.plugin(LocalBashExecutor) + // the executor seam, not ctx.fs). Use a catalog-only executor so the + // registration-time `rg` probe stays deterministic and the generator + // never depends on the host PATH. `ctx.spillStore` is optional (read via + // ctx.get) and does not affect the schemas, so no spill backend is mounted. + await ctx.plugin(CatalogSearchBashExecutor) await ctx.plugin(ToolFsSearch) }, 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.', + '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.', }, { pkg: '@deepseek-ai/dsh-tool-skill', From faae2d389b2c2f957fa6e61cc71b1b0e5885ef51 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 13 Jul 2026 16:43:16 +0800 Subject: [PATCH 08/88] test(fs-search): make loader guard rg-independent --- .../fs/tool-fs-search/tests/load-path.spec.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index d3c28619a3..90e4cc16cb 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -18,9 +18,69 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' + +/** + * Deterministic bash service for this Loader guard: the test wants to exercise + * the real unwrap/inject path, not depend on whether the host image has rg. + */ +class ProbeSuccessBashExecutor extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? '/work', + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + owner: request.owner, + sandboxMode: request.sandboxMode, + } + } + + override run(spec: BashExecSpec): Promise { + if (spec.command !== RG_PROBE_COMMAND) { + throw new Error(`unexpected command in load-path guard: ${spec.command}`) + } + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: spec.timeoutMs, + stdout: { text: '', truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashTask { + throw new Error('load-path guard must not start bash tasks') + } + + override get(): BashTask | undefined { + return undefined + } + + override ownerOf(): OwnerToken | undefined { + return undefined + } + + override list(): BashTask[] { + return [] + } + + override readOutput(id: BashTaskId): BashTaskRead { + throw new Error(`unknown bash task ${id}`) + } + + override kill(id: BashTaskId): boolean { + throw new Error(`unknown bash task ${id}`) + } +} + describe('dsh-tool-fs-search real-load-path guard', () => { it('has no default export and keeps name/inject/Config through unwrapExports', () => { expect('default' in toolFsSearch).toBe(false) @@ -38,7 +98,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(ProbeSuccessBashExecutor) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters[0] From 6be219a0bc7cd2554282fc41a565b5c399271071 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 17:47:42 +0800 Subject: [PATCH 09/88] 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 10/88] =?UTF-8?q?feat(sandbox):=20cross-family=20file=20sa?= =?UTF-8?q?ndbox=20=E2=80=94=20one=20policy=20home,=20sandboxed=20fs=20pro?= =?UTF-8?q?vider,=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 0a486f09c91d0e5a3198bb40c22f034b52cea466 Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 14 Jul 2026 23:34:47 +0800 Subject: [PATCH 11/88] chore: adopt node-addon-landlock-run source as native/ subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the node-addon-landlock-run tree (tag v0.0.1, commit 614f7fd) into native/landlock-run as its source of record: launcher development happens here, next to the harness consumers, and the standalone repository becomes the release mirror the tree is exported to for packing and publishing (procedure in native/README.md). The subtree keeps its own pnpm workspace and lockfile and is NOT added to the harness workspace: harness installs, gates, and CI never touch it. The mirror's .github/ stays out of the subtree; a separate manually-dispatched workflow (.github/workflows/landlock-run.yml) runs the subtree's CI legs — the per-architecture native builds, real-kernel launcher proofs, and pack rehearsal — adapted with working-directory/cache paths. eslint ignores the subtree like vendor/; AGENTS.md gains the native/ layout line (+5 words on its budget ceiling). --- .github/workflows/landlock-run.yml | 127 +++++++ AGENTS.md | 1 + eslint.config.mjs | 1 + native/README.md | 20 + native/landlock-run/.gitignore | 13 + native/landlock-run/AGENTS.md | 50 +++ native/landlock-run/LICENSE | 28 ++ native/landlock-run/README.md | 58 +++ native/landlock-run/docs/architecture.md | 34 ++ native/landlock-run/docs/cli-contract.md | 34 ++ native/landlock-run/docs/naming.md | 30 ++ native/landlock-run/docs/packaging.md | 45 +++ native/landlock-run/docs/release.md | 57 +++ native/landlock-run/docs/support-matrix.md | 18 + native/landlock-run/package.json | 30 ++ native/landlock-run/packages/entry/README.md | 16 + .../landlock-run/packages/entry/package.json | 36 ++ .../landlock-run/packages/entry/src/index.ts | 126 +++++++ native/landlock-run/packages/entry/src/main.c | 302 +++++++++++++++ .../landlock-run/packages/entry/tsconfig.json | 11 + .../landlock-run/packages/linux-arm64/LICENSE | 28 ++ .../packages/linux-arm64/README.md | 7 + .../packages/linux-arm64/package.json | 26 ++ .../packages/linux-arm64/prebuilds.json | 10 + .../landlock-run/packages/linux-x64/LICENSE | 28 ++ .../landlock-run/packages/linux-x64/README.md | 7 + .../packages/linux-x64/package.json | 26 ++ .../packages/linux-x64/prebuilds.json | 10 + native/landlock-run/pnpm-lock.yaml | 345 ++++++++++++++++++ native/landlock-run/pnpm-workspace.yaml | 8 + .../scripts/assemble-prebuilds.mjs | 51 +++ native/landlock-run/scripts/build.ts | 86 +++++ native/landlock-run/scripts/bump-release.mjs | 90 +++++ .../landlock-run/scripts/commit-release.mjs | 42 +++ native/landlock-run/scripts/github-matrix.mjs | 66 ++++ native/landlock-run/scripts/pack-release.mjs | 76 ++++ native/landlock-run/scripts/repo.mjs | 88 +++++ .../landlock-run/scripts/verify-entry-lib.mjs | 25 ++ .../scripts/verify-launcher-binary.mjs | 31 ++ .../scripts/verify-packed-install.mjs | 223 +++++++++++ .../landlock-run/scripts/verify-release.mjs | 52 +++ native/landlock-run/test/entry.test.js | 76 ++++ native/landlock-run/test/launcher.test.js | 121 ++++++ native/landlock-run/tsconfig.base.json | 11 + native/landlock-run/tsconfig.json | 11 + scripts/doc-budgets.manifest.json | 2 +- 46 files changed, 2582 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/landlock-run.yml create mode 100644 native/README.md create mode 100644 native/landlock-run/.gitignore create mode 100644 native/landlock-run/AGENTS.md create mode 100644 native/landlock-run/LICENSE create mode 100644 native/landlock-run/README.md create mode 100644 native/landlock-run/docs/architecture.md create mode 100644 native/landlock-run/docs/cli-contract.md create mode 100644 native/landlock-run/docs/naming.md create mode 100644 native/landlock-run/docs/packaging.md create mode 100644 native/landlock-run/docs/release.md create mode 100644 native/landlock-run/docs/support-matrix.md create mode 100644 native/landlock-run/package.json create mode 100644 native/landlock-run/packages/entry/README.md create mode 100644 native/landlock-run/packages/entry/package.json create mode 100644 native/landlock-run/packages/entry/src/index.ts create mode 100644 native/landlock-run/packages/entry/src/main.c create mode 100644 native/landlock-run/packages/entry/tsconfig.json create mode 100644 native/landlock-run/packages/linux-arm64/LICENSE create mode 100644 native/landlock-run/packages/linux-arm64/README.md create mode 100644 native/landlock-run/packages/linux-arm64/package.json create mode 100644 native/landlock-run/packages/linux-arm64/prebuilds.json create mode 100644 native/landlock-run/packages/linux-x64/LICENSE create mode 100644 native/landlock-run/packages/linux-x64/README.md create mode 100644 native/landlock-run/packages/linux-x64/package.json create mode 100644 native/landlock-run/packages/linux-x64/prebuilds.json create mode 100644 native/landlock-run/pnpm-lock.yaml create mode 100644 native/landlock-run/pnpm-workspace.yaml create mode 100644 native/landlock-run/scripts/assemble-prebuilds.mjs create mode 100644 native/landlock-run/scripts/build.ts create mode 100644 native/landlock-run/scripts/bump-release.mjs create mode 100644 native/landlock-run/scripts/commit-release.mjs create mode 100644 native/landlock-run/scripts/github-matrix.mjs create mode 100644 native/landlock-run/scripts/pack-release.mjs create mode 100644 native/landlock-run/scripts/repo.mjs create mode 100644 native/landlock-run/scripts/verify-entry-lib.mjs create mode 100644 native/landlock-run/scripts/verify-launcher-binary.mjs create mode 100644 native/landlock-run/scripts/verify-packed-install.mjs create mode 100644 native/landlock-run/scripts/verify-release.mjs create mode 100644 native/landlock-run/test/entry.test.js create mode 100644 native/landlock-run/test/launcher.test.js create mode 100644 native/landlock-run/tsconfig.base.json create mode 100644 native/landlock-run/tsconfig.json diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml new file mode 100644 index 0000000000..8916f59a56 --- /dev/null +++ b/.github/workflows/landlock-run.yml @@ -0,0 +1,127 @@ +# Manually-dispatched CI for the landlock-run source of record +# (native/landlock-run). A separate workflow from ci.yml on purpose: the +# subtree is a self-contained pnpm workspace with its own gates, exercised on +# demand — per-architecture native legs (build + behavioral tests + pack +# rehearsal on real kernels) plus one darwin leg proving the documented +# degradation on hosts without a platform package. Legs derive from the +# subtree's checked-in package matrix (scripts/github-matrix.mjs). Packing +# for npm happens in the release mirror (node-addon-landlock-run) after an +# export — see native/README.md; this workflow never packs for release. +name: Landlock Run + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: native/landlock-run + +jobs: + matrix: + name: Matrix + runs-on: ubuntu-24.04 + outputs: + ci: ${{ steps.matrix.outputs.ci }} + steps: + - uses: actions/checkout@v4 + + - id: matrix + run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT" + + native: + name: ${{ matrix.platform }} + needs: matrix + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.ci) }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: native/landlock-run/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: native/landlock-run/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install musl toolchain + run: | + sudo apt-get update -q + sudo apt-get install -yq musl-tools + + - name: Build TypeScript + run: pnpm build:ts + + - name: Typecheck + run: pnpm typecheck + + - name: Build native binaries (this architecture is the builder of record) + run: pnpm build:native + + - name: Entry tests (keyless) + run: node ./test/entry.test.js + + # NALR_REQUIRE_LANDLOCK: a self-skip on the very platform that exists to + # prove enforcement would be a false green, so an unenforcing kernel + # fails the leg instead of skipping. + - name: Launcher tests (real kernel enforcement) + run: node ./test/launcher.test.js + env: + NALR_REQUIRE_LANDLOCK: 1 + + - name: Pack rehearsal (pack → install → confine, this platform only) + run: | + node ./scripts/pack-release.mjs .release/npm --current-platform-only + node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only + env: + NALR_REQUIRE_LANDLOCK: 1 + + darwin: + name: darwin (no platform package — degradation proof) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + package_json_file: native/landlock-run/package.json + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: native/landlock-run/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build TypeScript + run: pnpm build:ts + + - name: Typecheck + run: pnpm typecheck + + - name: Entry tests (keyless) + run: node ./test/entry.test.js + + - name: Launcher tests (must self-skip cleanly) + run: node ./test/launcher.test.js + + - name: Pack rehearsal (entry only — fallback resolution + unusable probe) + run: | + node ./scripts/pack-release.mjs .release/npm --current-platform-only + node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only diff --git a/AGENTS.md b/AGENTS.md index 44aa15aea5..e5884ddd09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) +native/ node-addon-landlock-run source of record (see native/README.md) examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md) scripts/ repo gates and generators diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f0e8ce820..2ec78d51f9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default tseslint.config( '.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources '**/.doc-typecheck-*/**', 'vendor/**', // vendored source keeps upstream style and idioms + 'native/**', // imported landlock-run subtree: self-contained workspace with its own gates (native/README.md) '**/*.js', '**/*.mjs', '*.config.ts', // root tool configs (vitest, tsdown) — no project service diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000000..983e67f740 --- /dev/null +++ b/native/README.md @@ -0,0 +1,20 @@ +# native/ + +Source of record for `node-addon-landlock-run`, the Landlock self-restrict-then-exec launcher the harness consumes from npm (`packages/sandbox/sandbox-local`, `packages/bash/bash-sandbox`). Launcher development happens HERE, next to the consumers; the standalone repository is the release mirror that packs and publishes the npm package family. + +## Release mirror + +| Directory | Mirror repo | Last exported release | Commit | +|---|---|---|---| +| `landlock-run/` | https://github.com/deepseek-harness/node-addon-landlock-run | `v0.0.1` | `614f7fd7dc11e6eaceefba9e7ff1fbe28b51ba22` | + +The subtree is a self-contained pnpm workspace with its own `AGENTS.md`, docs, gates, and lockfile; it is NOT part of the harness workspace (`pnpm-workspace.yaml` does not include it), so harness installs, builds, and CI gates never touch it. The mirror's `.github/` stays out of the subtree — [.github/workflows/landlock-run.yml](../.github/workflows/landlock-run.yml) (manual dispatch) runs the subtree's CI legs here, and a change to those legs is mirrored into the mirror's `ci.yml` at the next export. + +## Export procedure (cutting a release) + +1. Land the launcher change here through a normal harness PR; dispatch the `Landlock Run` workflow and get its legs green. +2. In the mirror checkout, replace everything except `.github/`: `git -C rm -rq -- . ':!.github'`, then `git -C archive HEAD:native/landlock-run | tar -x -C `, then `git -C add -A` and commit. +3. In the mirror, follow its release checklist (`docs/release.md`): `pnpm release:commit ` → merge → tag `vX.Y.Z` → two-phase `Release` workflow (`publish=false` rehearsal, then `publish=true` from the tag). +4. Update the manifest table above with the released tag/commit, and bump the harness consumers' dependency range in the same change. + +The mirror must not diverge: a change committed there directly (hotfix during a release) is ported back here before the next export. diff --git a/native/landlock-run/.gitignore b/native/landlock-run/.gitignore new file mode 100644 index 0000000000..0d7597f2db --- /dev/null +++ b/native/landlock-run/.gitignore @@ -0,0 +1,13 @@ +# Built native binaries ride npm tarballs via each package's `files` list, +# never git. Root-level rules on purpose: a package-nested ignore file would +# also steer `pnpm pack` and has silently dropped payload from tarballs before. +packages/*/bin/ +packages/*/lib/ + +/.claude/ +/.release/ +dist/ +node_modules/ +/package-lock.json +*.log +*.tsbuildinfo diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md new file mode 100644 index 0000000000..31e12e177c --- /dev/null +++ b/native/landlock-run/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +This workspace builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. The source of record is the `deepseek-harness` repository's `native/landlock-run/`; the `node-addon-landlock-run` repository is the release mirror this tree is exported to for packing and publishing (procedure: `native/README.md` in the harness repo). Make changes in the source of record, never only in the mirror. + +## Pre-release stance + +The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. + +## Runtime safety rules + +- Every tool must fail closed. If a ruleset cannot be created or the kernel does not enforce it, exit non-zero WITHOUT exec'ing the wrapped command. Never run unconfined as a fallback. +- Runtime binaries and the entry packages take NO environment-variable overrides: which binary confines a process must never be decidable by the ambient environment. Test injection is by function parameter; the `NALR_*` prefix is for build/test orchestration only. +- Kernel UAPI is self-defined in the C source (verbatim from the kernel headers), keeping builds independent of toolchain header vintage and making the definitions part of the audit record. +- No libraries beyond libc, linked statically against musl. The audit surface of a tool is its C source plus the kernel's stable syscall contract. +- The CLI contract of each tool ([docs/cli-contract.md](docs/cli-contract.md)) is the cross-repo compatibility surface: argv grammar, exit codes, and report lines change only with a version bump and a changelog entry, and consumers parse them only through the entry package. +- There is deliberately NO install-time build fallback: a host without a matching platform package gets a nonexistent launcher path, the consumer's probe fails, and the consumer falls closed — that degradation is part of the design, not a gap to fill with node-gyp. + +## Repository layout + +```text +packages/entry/ Published entry package: JS seam (resolve/probe/grants) + the C source. +packages/linux-*/ Published per-platform packages: one prebuilt static binary, no JavaScript. +scripts/ Build, matrix derivation, prepack gates, and release orchestration. +test/ Plain-node behavioral tests (entry seam + real-kernel launcher proofs). +docs/ Architecture, packaging, CLI contract, release, support matrix, naming. +``` + +## Commands + +```sh +pnpm install +pnpm build:ts # entry packages → lib/ +pnpm build:native # this Linux architecture's binaries (needs musl-tools); fails fast elsewhere +pnpm typecheck +pnpm test # entry tests everywhere; launcher tests need linux + built binary +``` + +## Packaging invariants + +- The package matrix is explicit, checked-in metadata: `packages//package.json` (`os`, `cpu`), `packages//prebuilds.json` (the binaries that may exist there), and [docs/support-matrix.md](docs/support-matrix.md) stay synchronized when the matrix changes. `scripts/github-matrix.mjs` derives CI and release matrices from it; nothing else enumerates platforms. +- Platform package names contain platform only (`-linux-x64`), never tool variants — those stay inside `prebuilds.json`. Static musl linking is why there is no libc suffix: one binary serves glibc and musl distros. +- Platform packages ship no JavaScript; the entry package resolves them to file paths. Backends prove themselves at runtime through the functional probe, never through metadata trust. +- Builds are native-only: each architecture compiles its own binary on its own runner (CI is the builder of record); no cross toolchain enters the repo. +- Every tarball is gated at pack time: platform packages refuse to pack without their declared binaries present, executable, and in the right ELF architecture (`verify-launcher-binary.mjs`), entry packages without built `lib/` (`verify-entry-lib.mjs`), and the release pipeline byte-pins installed binaries against the workspace builds (`verify-packed-install.mjs`). +- Platform tarballs are packed with `npm pack`, never `pnpm pack`: pnpm's pack path strips the executable bit (observed on 11.7.0), shipping a launcher no consumer can spawn. `pack-release.mjs` encodes the split; the rehearsal asserts executability of the installed copy so a regression fails loudly instead of masquerading as a non-enforcing kernel. +- Generated artifacts stay out of git: `packages/*/bin/`, `packages/*/lib/`, `dist/`, `.release/`, `*.tsbuildinfo`. Ignore rules live in the ROOT `.gitignore` only — a package-nested ignore file can silently drop payload from tarballs. + +## Documentation + +User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md). diff --git a/native/landlock-run/LICENSE b/native/landlock-run/LICENSE new file mode 100644 index 0000000000..8187059c9a --- /dev/null +++ b/native/landlock-run/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, node-addon-landlock-run contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/landlock-run/README.md b/native/landlock-run/README.md new file mode 100644 index 0000000000..2bb92843e6 --- /dev/null +++ b/native/landlock-run/README.md @@ -0,0 +1,58 @@ +# node-addon-landlock-run + +A [Landlock](https://landlock.io/) self-restrict-then-exec launcher for confining subprocesses on Linux, distributed as prebuilt per-platform npm packages plus a thin JS entry package that resolves the binary and speaks its CLI contract. Built for agent harnesses and other hosts that need to run untrusted commands under a filesystem allow-list without confining themselves. + +The first tool is **`landlock-run`** — a self-restrict-then-exec [Landlock](https://landlock.io/) launcher (~300 lines of C11 over the raw kernel UAPI, statically linked against musl). It installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the command and every process it spawns run confined while the invoking process stays unrestricted. Fail-closed: if the kernel cannot enforce, it exits without running the command. + +## Install + +```sh +npm install node-addon-landlock-run +``` + +Published packages use an entry package plus platform optional packages: + +```text +node-addon-landlock-run +node-addon-landlock-run-linux-x64 +node-addon-landlock-run-linux-arm64 +``` + +npm's `os`/`cpu` fields make installers fetch only the matching platform package. There is no install-time build fallback on purpose: on a host without a platform package the resolved path never exists, the probe reports `unusable`, and the consumer falls closed. + +## Usage + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; + // spawn argv with your process runner of choice +} +``` + +The public API is intentionally small: + +- `launcherPath()`: absolute path of this host's launcher (existence deliberately unchecked — the probe is the availability signal). +- `probe(launcher?, { timeoutMs? })`: functional enforcement probe — `'full' | 'partial' | 'unusable'`. +- `grantArgs({ readOnly?, readWrite? })`: the launcher's grant argv; everything not granted is denied. +- `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT` (125): contract constants. + +The full binary contract (argv grammar, exit codes, report lines) is pinned in [docs/cli-contract.md](docs/cli-contract.md). + +## Support + +linux-x64 and linux-arm64, kernel with Landlock enabled (5.13+; ABI level determines `full` vs `partial` enforcement — see [docs/support-matrix.md](docs/support-matrix.md)). Other platforms deliberately have no package: consumers run different confinement backends there. + +## Development + +```sh +corepack enable +pnpm install +pnpm build:ts # entry packages → lib/ +pnpm build:native # this Linux architecture's binaries (apt-get install musl-tools) +pnpm test +``` + +Binaries are git-ignored and built natively per architecture — locally for your own machine, by CI's per-arch runners as the builders of record. Release flow: [docs/release.md](docs/release.md). diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md new file mode 100644 index 0000000000..e6974f4e50 --- /dev/null +++ b/native/landlock-run/docs/architecture.md @@ -0,0 +1,34 @@ +# Architecture + +This repository owns confinement *mechanism*, not policy: consumers (agent harnesses, sandbox seams) decide which paths a run may read or write; this package family provides the launcher that enforces those grants and the JS seam that resolves and speaks to it. The packaging follows the per-platform-package model of [`node-addon-require-builtin`](https://www.npmjs.com/package/@esplus/node-addon-require-builtin) (and esbuild), adapted from Node addons to standalone static executables. + +## Two-layer package family + +The family is one entry package plus per-platform binary packages: + +- **Entry package** (`node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. +- **Platform packages** (`node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. + +Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. + +There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively. + +## Resolution and availability + +`launcherPath()` resolves `node-addon-landlock-run--` and returns `/bin/landlock-run`. When the package is not resolvable it returns a deterministic fallback path inside the entry package's own `node_modules` that simply never exists. Existence is deliberately unchecked either way: `probe()` is the single availability signal, and a missing binary probes `unusable` exactly like an unenforcing kernel. Consumers get one degradation path, not two. + +The probe is functional — the launcher builds and enforces a real maximal ruleset in a short-lived child — because version checks would miss a kernel that has the syscalls but refuses enforcement. + +## Fail-closed everywhere + +The launcher exits `125` without exec'ing the command on any launcher-level failure: usage error, unenforcing kernel, unopenable grant root, failed exec. Partial enforcement (an older Landlock ABI governing only a subset of accesses) is accepted, reported on stderr, and surfaced by the probe as `partial` — the consumer decides what its mode vocabulary promises at each level. Neither the binary nor the entry package reads environment variables: which binary confines a process is never decidable by the ambient environment. + +## Build and release model + +Builds are native-only. `scripts/build.ts` compiles the running architecture's binaries with the distro `musl-gcc` (static: no loader or libc expectations on consumers, one binary for glibc and musl distros); CI's per-architecture runners are the builders of record, and no cross toolchain exists in the repo. The audit surface of a tool is its reviewed C source plus CI provenance, enforced by three gates: platform prepack refuses missing/wrong-ELF binaries, entry prepack refuses unbuilt `lib/`, and the release pipeline byte-pins installed binaries against the workspace builds they were packed from. + +The package matrix is checked-in metadata (`prebuilds.json` + `os`/`cpu` fields); `scripts/github-matrix.mjs` derives the CI and Release matrices from it, so adding a platform extends automation without editing workflows. + +## Adding a platform + +A new platform adds one `packages//` package (`package.json` with `os`/`cpu`, `prebuilds.json`, README, LICENSE), a runner entry in `scripts/github-matrix.mjs`, and a row in [support-matrix.md](support-matrix.md) — added only together with a native GitHub runner that builds and proves it (the no-cross-toolchain rule). Sibling launchers for other confinement mechanisms belong in their own repositories on this same template, not as second tools here. diff --git a/native/landlock-run/docs/cli-contract.md b/native/landlock-run/docs/cli-contract.md new file mode 100644 index 0000000000..57ab0f604c --- /dev/null +++ b/native/landlock-run/docs/cli-contract.md @@ -0,0 +1,34 @@ +# CLI contract: landlock-run + +This file pins the launcher's externally observable behavior — the cross-repo compatibility surface between the binaries and every consumer. Consumers interact with it only through the entry package (`launcherPath`/`probe`/`grantArgs`); changing anything below requires a version bump for the whole package family and a note in the release notes. + +## Invocation grammar + +```text +landlock-run [--ro ]... [--rw ]... -- ... +landlock-run --probe +``` + +- `--ro `: grant read + execute beneath ``. +- `--rw `: grant full filesystem access beneath `` (every access the negotiated kernel ABI can govern). +- Everything not granted is denied — Landlock rulesets are allow-lists. +- A grant on a non-directory keeps only its file-compatible access bits (this is how a `--rw /dev/null` grant works). +- `--`: mandatory separator; everything after it is the command argv, exec'd via `execvp` with the launcher's environment unchanged. +- `--probe`: mutually exclusive with grants and a command. +- No other flags, no environment-variable inputs. + +## Exit codes + +- `125` (`LAUNCHER_FAILURE_EXIT`): every launcher-level failure — usage error, kernel that cannot enforce Landlock, unopenable grant root, failed `exec`. The wrapped command was NOT run (fail-closed; the one exception is `exec` itself failing after restriction, which by definition never ran the command either). +- Any other status: the wrapped command's own exit status, passed through unchanged. +- `--probe`: `0` when the kernel enforces (fully or partially), `125` otherwise. + +## Report lines + +- Probe success prints exactly one stdout line: `landlock: fully enforced` or `landlock: partially enforced (older ABI)`. The entry package's `probe()` maps these to `full`/`partial`; a non-zero probe exit maps to `unusable`. +- A confined run under a partial-ABI kernel prints one stderr line `landlock-run: partial enforcement (older Landlock ABI)` and proceeds — still confined for everything the kernel supports. +- Every fatal error prints one stderr line prefixed `landlock-run: ` before exiting `125`. + +## Confinement semantics + +The launcher sets `no_new_privs`, installs the ruleset on itself, and `exec`s the command; the ruleset is inherited across `execve`, so every descendant process is equally confined. The ruleset governs the filesystem accesses of the kernel's negotiated Landlock ABI (up to ABI 5); accesses newer than the running ABI are not governed and are the difference between `full` and `partial`. diff --git a/native/landlock-run/docs/naming.md b/native/landlock-run/docs/naming.md new file mode 100644 index 0000000000..9de9f0b95f --- /dev/null +++ b/native/landlock-run/docs/naming.md @@ -0,0 +1,30 @@ +# Naming + +## npm packages + +The public package family is unscoped, using the `node-addon-landlock-run` package prefix; platform packages append platform information only: + +```text +node-addon-landlock-run +node-addon-landlock-run- +``` + +Platform suffixes carry no libc component (binaries are static musl) and no variant component — variants stay inside `prebuilds.json` and binary filenames. + +## Binaries + +The launcher executable is `landlock-run`, shipped at `bin/landlock-run` inside each platform package. + +## Environment variables + +The `NALR_` prefix (Node Addon Landlock Run) is reserved for build/test orchestration: + +```text +NALR_REQUIRE_LANDLOCK test-only: an unenforcing kernel fails instead of skipping +``` + +Runtime binaries and entry packages read NO environment variables — a runtime safety rule ([AGENTS.md](../AGENTS.md)), not a naming convention. Do not include the npm scope in environment variable names. + +## C symbols + +The launcher is a single C file with static linkage; there is no exported symbol namespace. Kernel UAPI constants keep their kernel names prefixed `LL_` where locally defined. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md new file mode 100644 index 0000000000..9a1be47b2a --- /dev/null +++ b/native/landlock-run/docs/packaging.md @@ -0,0 +1,45 @@ +# Packaging + +The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares. + +## Published packages + +```text +node-addon-landlock-run +node-addon-landlock-run-linux-x64 +node-addon-landlock-run-linux-arm64 +``` + +Unsupported platforms are intentionally absent from `optionalDependencies` — see [support-matrix.md](support-matrix.md). + +## Package matrix + +The matrix is explicit in checked-in metadata: + +- `packages/entry/package.json` lists the platform packages as `optionalDependencies`. +- `packages//package.json` declares `os` and `cpu`. There is no `libc` field on purpose: the binaries are statically linked against musl and run on glibc and musl distros alike. +- `packages//prebuilds.json` declares the binaries that may exist in that package (`tool`, `kind`, `path`). +- [support-matrix.md](support-matrix.md) explains why unsupported platform packages are not published. + +`scripts/github-matrix.mjs` derives the CI and Release matrices from these files. `scripts/build.ts` builds only the current host's targets, into `packages//bin/`; it is not a matrix generator. When changing the matrix, update package metadata, `prebuilds.json`, the lockfile, and the support/release docs in the same change. + +## Runtime selection + +1. npm's `os`/`cpu` fields make installers fetch only the matching platform package. +2. The entry package's `launcherPath()` resolves it to `/bin/landlock-run`; unresolvable packages yield a deterministic, never-existing fallback path. +3. `probe()` is the single availability signal: missing binary and unenforcing kernel are deliberately indistinguishable (`unusable`), so consumers have one fail-closed path. + +## No install fallback + +The entry package has NO install script and never compiles on the consumer host. A compile fallback would require a musl toolchain everywhere and turn a clean fail-closed degradation into an environment-dependent maybe. The packed-manifest check in `verify-packed-install.mjs` enforces the absence of install lifecycle scripts. + +## Pack gates + +Platform tarballs are produced by `npm pack`, entry tarballs by `pnpm pack` — deliberately split: `pnpm pack` (observed on 11.7.0) normalizes file modes and strips the executable bit, which would ship a launcher no consumer can spawn, while platform packages have no dependencies and so need none of pnpm's workspace-protocol conversion; entry packages need that conversion and carry no executables. `scripts/pack-release.mjs` encodes the split — never hand-pack a platform package with pnpm. + +Both pack paths produce the exact publish bytes behind a `prepack` gate: + +- Platform packages: `scripts/verify-launcher-binary.mjs` — every declared binary present, executable, ELF `e_machine` matching the declared `cpu`, nothing undeclared in `bin/`. +- Entry packages: `scripts/verify-entry-lib.mjs` — built `lib/` present. + +`scripts/verify-packed-install.mjs` then rehearses the consumer path from the packed tarballs: payload checks, a throwaway install, a byte-pin of the installed binary against the workspace build, an executability check on the installed copy, and a real confinement world-proof through the installed launcher. A non-executable or missing binary fails loudly here instead of masquerading as a non-enforcing kernel. diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md new file mode 100644 index 0000000000..e43b2d188c --- /dev/null +++ b/native/landlock-run/docs/release.md @@ -0,0 +1,57 @@ +# Release + +Pre-1.0: treat this as a release checklist, not a stability policy. + +## Versioning + +One version across every package in the repo. Use the bump helper: + +```sh +pnpm release:bump patch # or minor / major / x.y.z +``` + +It updates the root and every `packages/*` manifest, refreshes the lockfile (`--ignore-scripts --lockfile-only`), and runs `release:verify`. Explicit versions accept full semver including prereleases (`pnpm release:bump 0.0.0-test.0`); the publish workflow puts prerelease versions under the `next` dist-tag, so `latest` never points at a test build. Keep `workspace:*` dependencies in source; pnpm converts them to concrete versions during pack. + +Version bumps are normal source changes: open a release PR (or commit) with the manifests and lockfile, merge it, then create the matching `vX.Y.Z` tag from that commit. The publish workflow validates that the tag matches every package version. + +```sh +pnpm release:commit patch # bump + stage + commit in one command +git tag v0.0.2 +``` + +## Preflight + +```sh +pnpm install --frozen-lockfile +pnpm build:ts +pnpm typecheck +pnpm test # launcher half needs a Linux host with the binary built +``` + +On a Linux host, also rehearse the pack path locally: + +```sh +pnpm build:native +node ./scripts/pack-release.mjs .release/npm --current-platform-only +node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only +``` + +## Publish + +Use the `Release` workflow so every binary is built on its matching native runner: + +1. Run it with `publish=false` (from the release commit) to build all platform binaries, assemble and verify the payloads, pack the tarballs in publish order, rehearse the packed install, and upload the `npm-tarballs` artifact for inspection. +2. Create and push the `vX.Y.Z` tag matching the package versions. +3. Run the same workflow from that tag with `publish=true`. + +The workflow publishes only from the final packed tarballs, in `publish-order.txt` order (platform packages before the entry that optionally depends on them). It supports npm trusted publishing through GitHub OIDC; without it, provide an `NPM_TOKEN` secret in the `npm-publish` environment. Packages publish with `--access public`. + +Manual local fallback (current platform's packages only) — always through `pack-release.mjs`, never `pnpm publish` directly (pnpm's pack path strips the launcher's executable bit; see [packaging.md](packaging.md)): + +```sh +node ./scripts/pack-release.mjs dist/npm --current-platform-only +node ./scripts/verify-packed-install.mjs dist/npm --current-platform-only +while IFS= read -r tarball; do npm publish "dist/npm/${tarball}" --access public; done < dist/npm/publish-order.txt +``` + +Do not commit `.npmrc` files with tokens or registry overrides. diff --git a/native/landlock-run/docs/support-matrix.md b/native/landlock-run/docs/support-matrix.md new file mode 100644 index 0000000000..96d02b3cf6 --- /dev/null +++ b/native/landlock-run/docs/support-matrix.md @@ -0,0 +1,18 @@ +# Support matrix + +## Supported + +| Platform package | GitHub runner (builder of record) | Notes | +|---|---|---| +| `node-addon-landlock-run-linux-x64` | `ubuntu-24.04` | static musl — glibc and musl distros alike | +| `node-addon-landlock-run-linux-arm64` | `ubuntu-24.04-arm` | static musl — glibc and musl distros alike | + +Enforcement additionally requires a kernel with Landlock enabled (5.13+). The negotiated ABI level decides the probe verdict: every access this build knows governed → `full`; an older ABI governing a subset → `partial` (still confined for everything it supports); Landlock absent or disabled → `unusable`, and the launcher refuses to run commands at all. The probe — not the kernel version — is the authority: a kernel built without Landlock, or with the LSM disabled, probes `unusable` regardless of its version. + +## Deliberately unsupported + +- **darwin**: macOS consumers typically confine through `sandbox-exec`/Seatbelt, which ships with the OS — there is no binary to distribute. +- **win32**: a Windows confinement launcher would be a different mechanism in its own repository, not a port of this one. +- **Other Linux architectures** (riscv64, s390x, …): no native CI builder of record yet. The no-cross-toolchain rule means a platform package is added only together with a native runner that builds and proves it. + +A consumer on an unsupported platform resolves a nonexistent launcher path, probes `unusable`, and falls closed — the documented degradation, exercised by CI's darwin leg. diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json new file mode 100644 index 0000000000..f516588f17 --- /dev/null +++ b/native/landlock-run/package.json @@ -0,0 +1,30 @@ +{ + "name": "node-addon-landlock-run-workspace", + "version": "0.0.1", + "private": true, + "type": "module", + "license": "BSD-3-Clause", + "packageManager": "pnpm@11.7.0", + "scripts": { + "build": "pnpm build:ts", + "build:ts": "tsc -b", + "build:native": "tsx ./scripts/build.ts", + "typecheck": "tsc --noEmit && tsc -b --dry", + "test": "node ./test/entry.test.js && node ./test/launcher.test.js", + "test:entry": "node ./test/entry.test.js", + "test:launcher": "node ./test/launcher.test.js", + "gha:matrix": "node ./scripts/github-matrix.mjs", + "release:bump": "node ./scripts/bump-release.mjs", + "release:commit": "node ./scripts/commit-release.mjs", + "release:assemble-prebuilds": "node ./scripts/assemble-prebuilds.mjs", + "release:verify": "node ./scripts/verify-release.mjs", + "release:pack": "node ./scripts/pack-release.mjs", + "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" + }, + "devDependencies": { + "node-addon-landlock-run": "workspace:*", + "@types/node": "^24.10.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + } +} diff --git a/native/landlock-run/packages/entry/README.md b/native/landlock-run/packages/entry/README.md new file mode 100644 index 0000000000..789b1ddf6b --- /dev/null +++ b/native/landlock-run/packages/entry/README.md @@ -0,0 +1,16 @@ +# node-addon-landlock-run + +Landlock self-restrict-then-exec launcher for confining subprocesses on Linux: this entry package resolves the per-platform prebuilt binary, runs its functional enforcement probe, and builds its grant argv — consumers never spell launcher flags or parse launcher output themselves. + +```js +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const launcher = launcherPath(); +if (probe(launcher) !== 'unusable') { + const argv = [launcher, ...grantArgs({ readOnly: ['/'], readWrite: ['/tmp/work'] }), '--', 'bash', '-c', command]; +} +``` + +The launcher installs a Landlock ruleset on itself and `exec`s the wrapped command; the ruleset is inherited across `execve`, so the whole process tree runs confined. Everything not granted is denied, and launcher failures exit `125` without running the command — fail-closed, never fail-open. The binary contract is pinned in the repo's `docs/cli-contract.md`; the C source rides this tarball (`src/main.c`) for audit. + +Platform packages (`os`/`cpu`-selected optional dependencies, no JavaScript inside): `node-addon-landlock-run-linux-x64`, `node-addon-landlock-run-linux-arm64`. On hosts without one, `launcherPath()` returns a deterministic nonexistent path and `probe()` reports `'unusable'` — there is deliberately no install-time compile fallback. diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json new file mode 100644 index 0000000000..f05e81f06b --- /dev/null +++ b/native/landlock-run/packages/entry/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-addon-landlock-run", + "version": "0.0.1", + "type": "module", + "description": "Landlock self-restrict-then-exec launcher for sandboxing subprocesses on Linux: per-platform prebuilt static binaries plus the JS seam that resolves, probes, and speaks their CLI contract", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "README.md", + "lib/", + "!lib/*.tsbuildinfo", + "src/main.c" + ], + "scripts": { + "build:js": "tsc -b", + "prepack": "node ../../scripts/verify-entry-lib.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "optionalDependencies": { + "node-addon-landlock-run-linux-arm64": "workspace:*", + "node-addon-landlock-run-linux-x64": "workspace:*" + } +} diff --git a/native/landlock-run/packages/entry/src/index.ts b/native/landlock-run/packages/entry/src/index.ts new file mode 100644 index 0000000000..53de86122f --- /dev/null +++ b/native/landlock-run/packages/entry/src/index.ts @@ -0,0 +1,126 @@ +/** + * The JS seam over the prebuilt `landlock-run` launcher: resolve the + * binary for this host, build its grant argv, and run its functional probe. + * + * This module owns the launcher's CLI contract (`docs/cli-contract.md`) so + * consumers never parse launcher output or spell launcher flags themselves — + * the contract and the binaries version together in one package family, + * which makes probe-parsing drift against the binary structurally + * impossible. Policy stays with the consumer: this package does not know + * what a "sandbox mode" is, only which paths are granted read or write. + * + * Deliberately no environment-variable overrides anywhere in this module: + * which binary confines a process must never be decidable by the ambient + * environment. Test injection is by function parameter. + */ +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** The launcher binary's file name inside each platform package's `bin/`. */ +export const LAUNCHER_BIN = 'landlock-run' + +/** + * The exit code for every launcher-level failure (usage error, unenforcing + * kernel, unopenable grant root, failed exec) — chosen because the wrapped + * command itself is unlikely to use it, so a consumer can tell launcher + * failures from command failures. Part of the CLI contract. + */ +export const LAUNCHER_FAILURE_EXIT = 125 + +/** + * The probe's verdict on this host: `full` when the running kernel enforces + * every access the launcher can govern, `partial` when an older Landlock ABI + * governs only a subset (still confined for everything it supports), and + * `unusable` when nothing can be enforced — a kernel without Landlock, a + * disabled LSM, or a missing binary, all indistinguishable on purpose + * because the consumer's answer is the same: do not trust this launcher. + */ +export type LandlockEnforcement = 'full' | 'partial' | 'unusable' + +/** + * Filesystem grants for one confined run. Everything not granted is denied — + * Landlock rulesets are allow-lists. + */ +export interface LauncherGrants { + /** Roots granted read + execute beneath (the launcher's `--ro`). */ + readonly readOnly?: readonly string[] + /** Roots granted full filesystem access beneath (the launcher's `--rw`). */ + readonly readWrite?: readonly string[] +} + +/** + * Path of the launcher binary for this host: resolved from the per-platform + * npm package `node-addon-landlock-run--` (npm's + * `os`/`cpu` fields make installers fetch only the matching one). When the + * package is not resolvable — a platform without one, or an install that + * skipped the optional dependency — the returned fallback path points inside + * this package's own `node_modules` and simply never exists. Existence is + * deliberately not checked either way: {@link probe} is the single + * availability signal (a missing binary probes `unusable` the same way an + * unenforcing kernel does). + * @param resolvePackageJson - test seam over `require.resolve` (the default + * covers real installs); receives the platform package's `package.json` + * specifier and returns its absolute path, throwing when unresolvable. + * @returns the absolute launcher path to probe and exec. + */ +export function launcherPath( + resolvePackageJson: (specifier: string) => string = createRequire(import.meta.url).resolve, +): string { + const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}` + try { + return join(dirname(resolvePackageJson(`${platformPackage}/package.json`)), 'bin', LAUNCHER_BIN) + } catch { + // Unresolvable platform package: no such package exists for this host, or + // it was not installed. Fall back to the path pnpm's layout WOULD use — + // absolute, inside this package's boundary (never cwd-relative: a + // spawnable relative path here would hand cwd control over which binary + // confines), and nonexistent exactly when the package is absent. + return fileURLToPath(new URL(`../node_modules/${platformPackage}/bin/${LAUNCHER_BIN}`, import.meta.url)) + } +} + +/** + * The launcher grant arguments for one set of filesystem grants — everything + * before the `--` argv separator. A caller spawns + * `[launcherPath(), ...grantArgs(grants), '--', ...command]`; the flag + * spellings stay private to this package. + * @param grants - the read-only and read-write roots to allow. + * @returns the `--ro ` / `--rw ` argument list, read-only roots + * first, in the caller's order. + */ +export function grantArgs(grants: LauncherGrants): string[] { + return [ + ...(grants.readOnly ?? []).flatMap(root => ['--ro', root]), + ...(grants.readWrite ?? []).flatMap(root => ['--rw', root]), + ] +} + +/** + * Functional probe: `landlock-run --probe` builds and enforces a maximal + * ruleset in a short-lived child and exits 0 only when the running kernel + * actually enforces it — `--version`-style checks would miss a kernel that + * has the syscalls but refuses enforcement. The probe's one report line is + * part of the CLI contract and distinguishes complete from per-ABI-subset + * enforcement; a zero exit without the partial marker reads as `full`. A + * failed or timed-out spawn (missing binary, wrong architecture, unenforcing + * kernel) probes `unusable`. Synchronous by design: consumers run it once + * and cache the verdict. + * @param launcher - the launcher path to probe; defaults to + * {@link launcherPath}'s resolution for this host. + * @param options - `timeoutMs` bounds the probe child (default 2000). + * @returns the enforcement verdict for this host. + */ +export function probe( + launcher: string = launcherPath(), + options: { timeoutMs?: number } = {}, +): LandlockEnforcement { + const result = spawnSync(launcher, ['--probe'], { + timeout: options.timeoutMs ?? 2000, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (result.status !== 0) return 'unusable' + return /partially enforced/.test(result.stdout) ? 'partial' : 'full' +} diff --git a/native/landlock-run/packages/entry/src/main.c b/native/landlock-run/packages/entry/src/main.c new file mode 100644 index 0000000000..2535f8bc31 --- /dev/null +++ b/native/landlock-run/packages/entry/src/main.c @@ -0,0 +1,302 @@ +/* + * landlock-run: self-restrict-then-exec Landlock launcher. + * + * The Landlock rung of a consuming sandbox seam, for Linux hosts where + * `bwrap` is + * unusable (not installed, unprivileged user namespaces disabled, or an LSM + * profile that denies mount — Landlock is an independent syscall family and + * needs none of those). The launcher installs a Landlock + * ruleset on itself and `exec`s the wrapped command; the ruleset is inherited + * across `execve`, so the command (and every process it spawns) runs confined + * while the invoking process stays unrestricted. + * + * CLI contract (mirrors the `bwrap` runner argv shape the executor wraps): + * + * landlock-run [--ro ]... [--rw ]... -- ... + * landlock-run --probe + * + * `--ro` grants read+execute beneath the path; `--rw` grants full filesystem + * access beneath the path. Everything else is denied (Landlock is an + * allow-list). `--probe` builds a maximal ruleset and reports whether the + * running kernel actually enforces it — the executor's functional probe. + * + * Fail-closed: if the ruleset cannot be created or is NOT enforced by the + * kernel, the launcher exits non-zero WITHOUT exec'ing the command. A partial + * (best-effort) enforcement on an older ABI is accepted and reported on + * stderr; the consumer's mode vocabulary keeps its file-effect promises + * honest per ABI level (surfaced as `full` vs `partial` by the entry + * package's probe). + * + * Plain C11 over the raw Landlock UAPI — no libraries beyond libc (musl, + * linked statically), so the whole audit surface is this file plus the + * kernel's stable syscall contract. Built natively per architecture by + * `scripts/build.ts` into the per-platform npm packages + * (`node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar, + * exit codes, and report lines are pinned in `docs/cli-contract.md`. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * The Landlock UAPI, defined locally instead of via : the + * kernel's user-space ABI is stable by contract, self-defining it keeps the + * build independent of the toolchain's header vintage, and the definitions + * double as the audit record of exactly which kernel surface this launcher + * touches. Layouts and values are verbatim from the kernel header (the + * path-beneath struct is packed there, so it must be packed here). + */ +struct landlock_ruleset_attr { + uint64_t handled_access_fs; +}; + +struct landlock_path_beneath_attr { + uint64_t allowed_access; + int32_t parent_fd; +} __attribute__((packed)); + +#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0) +#define LANDLOCK_RULE_PATH_BENEATH 1 + +/* Filesystem access bits, grouped by the Landlock ABI that introduced them. */ +#define LL_FS_EXECUTE (UINT64_C(1) << 0) /* ABI 1 */ +#define LL_FS_WRITE_FILE (UINT64_C(1) << 1) +#define LL_FS_READ_FILE (UINT64_C(1) << 2) +#define LL_FS_READ_DIR (UINT64_C(1) << 3) +#define LL_FS_REMOVE_DIR (UINT64_C(1) << 4) +#define LL_FS_REMOVE_FILE (UINT64_C(1) << 5) +#define LL_FS_MAKE_CHAR (UINT64_C(1) << 6) +#define LL_FS_MAKE_DIR (UINT64_C(1) << 7) +#define LL_FS_MAKE_REG (UINT64_C(1) << 8) +#define LL_FS_MAKE_SOCK (UINT64_C(1) << 9) +#define LL_FS_MAKE_FIFO (UINT64_C(1) << 10) +#define LL_FS_MAKE_BLOCK (UINT64_C(1) << 11) +#define LL_FS_MAKE_SYM (UINT64_C(1) << 12) +#define LL_FS_REFER (UINT64_C(1) << 13) /* ABI 2 */ +#define LL_FS_TRUNCATE (UINT64_C(1) << 14) /* ABI 3 (ABI 4 added TCP bits only) */ +#define LL_FS_IOCTL_DEV (UINT64_C(1) << 15) /* ABI 5 */ + +#define LL_ABI1_MASK (LL_FS_REFER - 1) /* bits 0..12: every ABI-1 access, nothing newer */ + +/* + * Newest ABI this build knows; the negotiation below scales the actual + * ruleset down to what the running kernel supports (the best-effort compat + * stance of the previous Rust launcher, made explicit). + */ +#define MAX_ABI 5L + +/* + * Landlock has no libc wrappers; these are the raw syscalls. The numbers are + * identical on every architecture (the post-2011 unified table) — the + * fallbacks only matter to a libc older than the feature. + */ +#ifndef __NR_landlock_create_ruleset +#define __NR_landlock_create_ruleset 444 +#define __NR_landlock_add_rule 445 +#define __NR_landlock_restrict_self 446 +#endif + +/* + * Every fatal launcher error prints `landlock-run: ` to stderr + * and exits 125 — a code the wrapped command itself is unlikely to use, so + * the executor can tell launcher failures from command failures. + */ +#define EXIT_LAUNCHER_FAILURE 125 + +static const char NOT_ENFORCED_MESSAGE[] = + "landlock is not enforced by this kernel (ABI unsupported or disabled)"; + +/* Print one fatal `landlock-run: ...` line; returns the fatal exit code. */ +static int fail(const char *prefix, const char *detail) { + if (detail == NULL) { + fprintf(stderr, "landlock-run: %s\n", prefix); + } else { + fprintf(stderr, "landlock-run: %s: %s\n", prefix, detail); + } + return EXIT_LAUNCHER_FAILURE; +} + +static int fail_usage(const char *message, const char *detail) { + fprintf(stderr, "landlock-run: usage error: %s%s\n", message, detail == NULL ? "" : detail); + return EXIT_LAUNCHER_FAILURE; +} + +/* Parsed CLI: either a probe, or grants plus the command argv after `--`. */ +struct cli { + int probe; + const char **ro; + size_t ro_count; + const char **rw; + size_t rw_count; + char **command; /* NULL-terminated tail of main's argv */ +}; + +/* + * Hand-rolled argv parsing — four flags do not justify a parsing library, + * and the previous Rust launcher made the same call for the same reason. + * Returns 0 on success, else the process exit code (message already printed). + */ +static int parse(int argc, char **argv, struct cli *cli) { + /* argc bounds each grant list; the launcher execs or exits, so no free. */ + cli->ro = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->ro); + cli->rw = calloc(argc > 0 ? (size_t)argc : 1, sizeof *cli->rw); + if (cli->ro == NULL || cli->rw == NULL) return fail("out of memory", NULL); + + int index = 1; + while (index < argc) { + const char *arg = argv[index]; + if (strcmp(arg, "--probe") == 0) { + cli->probe = 1; + index += 1; + } else if (strcmp(arg, "--ro") == 0 || strcmp(arg, "--rw") == 0) { + if (index + 1 >= argc) { + return fail_usage(arg, " requires a path"); + } + if (strcmp(arg, "--ro") == 0) { + cli->ro[cli->ro_count++] = argv[index + 1]; + } else { + cli->rw[cli->rw_count++] = argv[index + 1]; + } + index += 2; + } else if (strcmp(arg, "--") == 0) { + cli->command = &argv[index + 1]; + break; + } else { + return fail_usage("unknown argument: ", arg); + } + } + if (cli->probe) { + if (cli->ro_count > 0 || cli->rw_count > 0 || (cli->command != NULL && cli->command[0] != NULL)) { + return fail_usage("--probe takes no other arguments", NULL); + } + } else if (cli->command == NULL || cli->command[0] == NULL) { + return fail_usage("missing `-- ...` command", NULL); + } + return 0; +} + +/* The filesystem accesses the running kernel's ABI can govern. */ +static uint64_t fs_mask_for_abi(long abi) { + uint64_t mask = LL_ABI1_MASK; + if (abi >= 2) mask |= LL_FS_REFER; + if (abi >= 3) mask |= LL_FS_TRUNCATE; + if (abi >= 5) mask |= LL_FS_IOCTL_DEV; + return mask; +} + +/* Add one path-beneath rule; 0 on success, else the exit code. */ +static int add_rule(int ruleset_fd, const char *path, uint64_t access) { + int path_fd = open(path, O_PATH | O_CLOEXEC); + if (path_fd < 0) { + /* Fail closed on an unopenable grant root: silently narrowing the + * granted set would be safe, but running with a profile the caller did + * not get is not worth the ambiguity. */ + fprintf(stderr, "landlock-run: cannot open rule path: %s: %s\n", path, strerror(errno)); + return EXIT_LAUNCHER_FAILURE; + } + /* The kernel rejects directory-only accesses on a non-directory rule + * (EINVAL), so a file grant keeps only the file-compatible bits — how the + * `--rw /dev/null` grant works. Same clamp the Rust crate's + * path_beneath_rules helper applied. */ + struct stat st; + if (fstat(path_fd, &st) == 0 && !S_ISDIR(st.st_mode)) { + access &= LL_FS_EXECUTE | LL_FS_WRITE_FILE | LL_FS_READ_FILE | LL_FS_TRUNCATE | LL_FS_IOCTL_DEV; + } + struct landlock_path_beneath_attr attr = { .allowed_access = access, .parent_fd = path_fd }; + if (syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &attr, 0) != 0) { + int saved = errno; + close(path_fd); + return fail("landlock ruleset error", strerror(saved)); + } + close(path_fd); + return 0; +} + +/* + * Install the ruleset on the current thread, negotiating the kernel's ABI + * down from MAX_ABI. `--ro` paths get the read side of the vocabulary (read + * file/dir + execute — the wrapped `bash` and everything it spawns must + * remain executable); `--rw` paths get every filesystem access the + * negotiated ABI can grant. Sets `no_new_privs` first (mandatory for an + * unprivileged restrict, and it neutralizes setuid/setgid escalation inside + * the sandbox). On success `*partial` reports whether the kernel governs + * only a subset of MAX_ABI's accesses. Returns 0, else the exit code. + */ +static int restrict_self(const struct cli *cli, int *partial) { + long abi = syscall(__NR_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 0) { + /* ENOSYS: kernel built without Landlock; EOPNOTSUPP: built but disabled. + * Either way: not enforceable — fail CLOSED, never exec unconfined. */ + return fail(NOT_ENFORCED_MESSAGE, NULL); + } + *partial = abi < MAX_ABI; + uint64_t handled = fs_mask_for_abi(abi < MAX_ABI ? abi : MAX_ABI); + + struct landlock_ruleset_attr attr = { .handled_access_fs = handled }; + int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof attr, 0); + if (ruleset_fd < 0) return fail("landlock ruleset error", strerror(errno)); + + const uint64_t read_side = LL_FS_EXECUTE | LL_FS_READ_FILE | LL_FS_READ_DIR; + for (size_t i = 0; i < cli->ro_count; i++) { + int code = add_rule(ruleset_fd, cli->ro[i], read_side & handled); + if (code != 0) return code; + } + for (size_t i = 0; i < cli->rw_count; i++) { + int code = add_rule(ruleset_fd, cli->rw[i], handled); + if (code != 0) return code; + } + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + return fail("landlock ruleset error", strerror(errno)); + } + if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) { + return fail("landlock ruleset error", strerror(errno)); + } + close(ruleset_fd); + return 0; +} + +int main(int argc, char **argv) { + struct cli cli = { 0 }; + int code = parse(argc, argv, &cli); + if (code != 0) return code; + + if (cli.probe) { + /* The functional probe: build and enforce a maximal ruleset in THIS + * short-lived process (the probe run exits right after). `--version` + * style checks would miss a kernel that has the syscalls but refuses + * enforcement; actually restricting is the only honest signal. The one + * report line is part of the launcher CLI contract — the executor reads + * enforcement completeness from it. */ + static const char *probe_root = "/"; + struct cli probe = { .ro = &probe_root, .ro_count = 1 }; + int partial = 0; + code = restrict_self(&probe, &partial); + if (code != 0) return code; + printf("landlock: %s\n", partial ? "partially enforced (older ABI)" : "fully enforced"); + return 0; + } + + int partial = 0; + code = restrict_self(&cli, &partial); + if (code != 0) return code; + if (partial) { + /* Older ABI: some handled accesses are not governed (e.g. truncate + * before ABI 3). Still confined for everything the kernel supports — + * report, do not refuse. */ + fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n"); + } + + execvp(cli.command[0], cli.command); + /* exec only returns on failure. */ + return fail("exec failed", strerror(errno)); +} diff --git a/native/landlock-run/packages/entry/tsconfig.json b/native/landlock-run/packages/entry/tsconfig.json new file mode 100644 index 0000000000..bb991d6ceb --- /dev/null +++ b/native/landlock-run/packages/entry/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "lib", + "rootDir": "src", + "tsBuildInfoFile": "lib/.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/native/landlock-run/packages/linux-arm64/LICENSE b/native/landlock-run/packages/linux-arm64/LICENSE new file mode 100644 index 0000000000..8187059c9a --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, node-addon-landlock-run contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/landlock-run/packages/linux-arm64/README.md b/native/landlock-run/packages/linux-arm64/README.md new file mode 100644 index 0000000000..1921c8f4b5 --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/README.md @@ -0,0 +1,7 @@ +# node-addon-landlock-run-linux-arm64 + +Prebuilt `bin/landlock-run` Landlock launcher for linux-arm64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. + +The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. + +Sibling: `node-addon-landlock-run-linux-x64`. diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json new file mode 100644 index 0000000000..0067f77c8b --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-addon-landlock-run-linux-arm64", + "version": "0.0.1", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-arm64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "README.md", + "bin/", + "prebuilds.json" + ], + "scripts": { + "prepack": "node ../../scripts/verify-launcher-binary.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + } +} diff --git a/native/landlock-run/packages/linux-arm64/prebuilds.json b/native/landlock-run/packages/linux-arm64/prebuilds.json new file mode 100644 index 0000000000..81e6b429f7 --- /dev/null +++ b/native/landlock-run/packages/linux-arm64/prebuilds.json @@ -0,0 +1,10 @@ +{ + "platform": "linux-arm64", + "binaries": [ + { + "tool": "landlock-run", + "kind": "static-musl", + "path": "bin/landlock-run" + } + ] +} diff --git a/native/landlock-run/packages/linux-x64/LICENSE b/native/landlock-run/packages/linux-x64/LICENSE new file mode 100644 index 0000000000..8187059c9a --- /dev/null +++ b/native/landlock-run/packages/linux-x64/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, node-addon-landlock-run contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/native/landlock-run/packages/linux-x64/README.md b/native/landlock-run/packages/linux-x64/README.md new file mode 100644 index 0000000000..ce741eb34c --- /dev/null +++ b/native/landlock-run/packages/linux-x64/README.md @@ -0,0 +1,7 @@ +# node-addon-landlock-run-linux-x64 + +Prebuilt `bin/landlock-run` Landlock launcher for linux-x64 — a static musl binary compiled natively (no cross toolchain) from the C source shipped in [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run). npm's `os`/`cpu` fields select this package at install time; the entry package resolves it to a file path — it ships no JavaScript and is never imported. + +The binary is git-ignored and rides the npm tarball via the `files` list; the `prepack` gate refuses to pack when it is missing or has the wrong ELF architecture, and the release pipeline byte-pins the packed binary against the CI build it came from. Static musl linking means one binary for glibc and musl distros alike — hence no libc suffix in the name. + +Sibling: `node-addon-landlock-run-linux-arm64`. diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json new file mode 100644 index 0000000000..8ea60b636c --- /dev/null +++ b/native/landlock-run/packages/linux-x64/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-addon-landlock-run-linux-x64", + "version": "0.0.1", + "description": "Prebuilt landlock-run Landlock launcher binary for linux-x64 (static musl) — resolved as a file path by node-addon-landlock-run, never imported", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "README.md", + "bin/", + "prebuilds.json" + ], + "scripts": { + "prepack": "node ../../scripts/verify-launcher-binary.mjs" + }, + "engines": { + "node": ">=20" + }, + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + } +} diff --git a/native/landlock-run/packages/linux-x64/prebuilds.json b/native/landlock-run/packages/linux-x64/prebuilds.json new file mode 100644 index 0000000000..27b0de360c --- /dev/null +++ b/native/landlock-run/packages/linux-x64/prebuilds.json @@ -0,0 +1,10 @@ +{ + "platform": "linux-x64", + "binaries": [ + { + "tool": "landlock-run", + "kind": "static-musl", + "path": "bin/landlock-run" + } + ] +} diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml new file mode 100644 index 0000000000..88b1b3df00 --- /dev/null +++ b/native/landlock-run/pnpm-lock.yaml @@ -0,0 +1,345 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + node-addon-landlock-run: + specifier: workspace:* + version: link:packages/entry + tsx: + specifier: ^4.20.6 + version: 4.23.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + packages/entry: + optionalDependencies: + node-addon-landlock-run-linux-arm64: + specifier: workspace:* + version: link:../linux-arm64 + node-addon-landlock-run-linux-x64: + specifier: workspace:* + version: link:../linux-x64 + + packages/linux-arm64: {} + + packages/linux-x64: {} + +packages: + + '@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.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + 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.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@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.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + 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.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + 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.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + 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.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + 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.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + 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.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} diff --git a/native/landlock-run/pnpm-workspace.yaml b/native/landlock-run/pnpm-workspace.yaml new file mode 100644 index 0000000000..22299bfea0 --- /dev/null +++ b/native/landlock-run/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - packages/* + +# pnpm 10+ blocks any dependency shipping an install/build script until it is +# explicitly reviewed here. Deny by default; esbuild (tsx's bundled native +# binary) genuinely needs its script. +allowBuilds: + esbuild: true diff --git a/native/landlock-run/scripts/assemble-prebuilds.mjs b/native/landlock-run/scripts/assemble-prebuilds.mjs new file mode 100644 index 0000000000..4dcdb23bed --- /dev/null +++ b/native/landlock-run/scripts/assemble-prebuilds.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Assemble downloaded release artifacts into the platform packages and + * verify the result. The Release workflow's build legs upload one + * `prebuild-` artifact per platform package (its `bin/` payload); + * this script copies each into `packages//bin/` and then checks + * every declared binary for presence and ELF architecture. + * + * Usage: `node scripts/assemble-prebuilds.mjs `. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { platformDirs, root, verifyPlatformBinaries } from './repo.mjs'; + +const artifactRoot = path.resolve(process.argv[2] || '.release/prebuild-artifacts'); + +if (!fs.existsSync(artifactRoot)) { + throw new Error(`prebuild artifact directory does not exist: ${artifactRoot}`); +} + +const platforms = platformDirs().map((dir) => path.basename(dir)); + +for (const name of platforms) { + const binDir = path.join(root, 'packages', name, 'bin'); + fs.rmSync(binDir, { recursive: true, force: true }); + fs.mkdirSync(binDir, { recursive: true }); +} + +for (const artifactName of fs.readdirSync(artifactRoot)) { + const artifactDir = path.join(artifactRoot, artifactName); + if (!fs.statSync(artifactDir).isDirectory()) continue; + + const name = platforms.find((candidate) => artifactName === `prebuild-${candidate}`); + if (!name) { + throw new Error(`cannot map artifact to a platform package: ${artifactName}`); + } + + for (const file of fs.readdirSync(artifactDir)) { + const source = path.join(artifactDir, file); + const destination = path.join(root, 'packages', name, 'bin', file); + fs.copyFileSync(source, destination); + fs.chmodSync(destination, 0o755); + console.log(`Copied ${path.relative(root, source)} -> ${path.relative(root, destination)}`); + } +} + +for (const dir of platformDirs()) { + const { name, count } = verifyPlatformBinaries(path.join(root, dir)); + console.log(`Verified ${name}: ${count} binaries`); +} diff --git a/native/landlock-run/scripts/build.ts b/native/landlock-run/scripts/build.ts new file mode 100644 index 0000000000..5866cc0dc4 --- /dev/null +++ b/native/landlock-run/scripts/build.ts @@ -0,0 +1,86 @@ +/** + * Build every native tool this host can build, into its per-platform + * package. + * + * Targets are derived from the checked-in matrix: each + * `packages//prebuilds.json` whose `platform` matches this host names + * the binaries to produce; the TOOLS table below maps each `tool` to its C + * source. Builds are NATIVE-ONLY — each Linux architecture compiles its own + * binary with the distro's `musl-gcc` (static musl: runs on glibc and musl + * distros alike, no loader or libc expectations on the consumer host), and + * CI's per-arch runners are the builders of record. No cross toolchain + * exists here on purpose: native runners replace it, and the audit surface + * is the reviewed C source plus CI provenance. + * + * Binaries land in `packages//bin/` — git-ignored (root + * `.gitignore`), packed into the platform package's npm tarball behind its + * `prepack` gate (`scripts/verify-launcher-binary.mjs`). + * + * Run: `pnpm run build:native` (Linux with musl-gcc on PATH: + * `apt-get install musl-tools`). Non-Linux hosts fail fast — no platform + * package exists for them to build. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' + +/** Each native tool's C source, keyed by the `tool` field in prebuilds.json. */ +const TOOLS: Record = { + 'landlock-run': { source: 'packages/entry/src/main.c' }, +} + +const repoRoot = resolve(import.meta.dirname, '..') + +if (process.platform !== 'linux') { + console.error(`build: native tools are built natively per Linux architecture (no cross toolchain) — nothing to build on ${process.platform}. CI's per-arch runners build and rehearse every platform package.`) + process.exit(1) +} +const hostPlatform = `linux-${process.arch}` + +/** This host's platform packages, from the checked-in matrix. */ +const targets: { packageDir: string; tool: string; binaryPath: string; kind: string }[] = [] +const packagesRoot = join(repoRoot, 'packages') +for (const name of readdirSync(packagesRoot).sort()) { + const prebuildsFile = join(packagesRoot, name, 'prebuilds.json') + if (!existsSync(prebuildsFile)) continue + const prebuilds = JSON.parse(readFileSync(prebuildsFile, 'utf8')) as { + platform: string + binaries: { tool: string; kind: string; path: string }[] + } + if (prebuilds.platform !== hostPlatform) continue + for (const binary of prebuilds.binaries) { + targets.push({ packageDir: join(packagesRoot, name), tool: binary.tool, binaryPath: binary.path, kind: binary.kind }) + } +} +if (targets.length === 0) { + console.error(`build: no platform package declares binaries for ${hostPlatform} — supported platforms are the packages/*/prebuilds.json "platform" values.`) + process.exit(1) +} + +for (const target of targets) { + const tool = TOOLS[target.tool] + if (tool === undefined) { + console.error(`build: prebuilds.json names unknown tool "${target.tool}" — add it to the TOOLS table in scripts/build.ts.`) + process.exit(1) + } + if (target.kind !== 'static-musl') { + console.error(`build: unknown binary kind "${target.kind}" — the only toolchain here is static musl.`) + process.exit(1) + } + const binary = join(target.packageDir, target.binaryPath) + mkdirSync(dirname(binary), { recursive: true }) + + // -static against musl: self-contained, no loader/libc expectations on the + // consumer host. -Werror is safe to keep hard: CI pins the builder images, + // and a new warning on a toolchain bump deserves a look, not a pass. + const result = spawnSync('musl-gcc', [ + '-std=c11', '-Os', '-Wall', '-Wextra', '-Werror', '-static', '-s', + '-o', binary, join(repoRoot, tool.source), + ], { stdio: ['ignore', 'inherit', 'inherit'] }) + if (result.error !== undefined || result.status !== 0) { + console.error('build: musl-gcc failed' + + (result.error ? ` (${result.error.message} — is musl-tools installed?)` : '')) + process.exit(1) + } + console.log(`build: built ${basename(target.packageDir)}/${target.binaryPath}`) +} diff --git a/native/landlock-run/scripts/bump-release.mjs b/native/landlock-run/scripts/bump-release.mjs new file mode 100644 index 0000000000..29a7777379 --- /dev/null +++ b/native/landlock-run/scripts/bump-release.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +/** + * Bump every package (workspace root + packages/*) to one version, refresh + * the lockfile, and verify. Usage: `pnpm release:bump `. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { packageDirs, readJson, root } from './repo.mjs'; + +const bump = process.argv[2]; +const releaseTypes = new Set(['major', 'minor', 'patch']); + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + stdio: 'inherit', + env: { ...process.env, CI: 'true' }, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function packageFiles() { + return ['package.json', ...packageDirs().map((dir) => path.join(dir, 'package.json'))]; +} + +function parseVersion(version) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match) { + throw new Error(`increment types need a plain x.y.z current version (current: ${version}) — pass an explicit target version instead`); + } + return match.slice(1).map((part) => Number(part)); +} + +/** Explicit target versions accept full semver, prereleases included (test publishes). */ +const EXPLICIT_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; + +function nextVersion(current, release) { + if (EXPLICIT_VERSION.test(release)) return release; + + if (!releaseTypes.has(release)) { + throw new Error('Usage: pnpm release:bump '); + } + + const [major, minor, patch] = parseVersion(current); + if (release === 'major') return `${major + 1}.0.0`; + if (release === 'minor') return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +function currentPublishedVersion(files) { + const versions = new Set( + files + .filter((file) => file.startsWith('packages/')) + .map((file) => readJson(path.join(root, file)).version), + ); + if (versions.size !== 1) { + throw new Error(`published package versions differ: ${[...versions].join(', ')}`); + } + return [...versions][0]; +} + +if (!bump) { + console.error('Usage: pnpm release:bump '); + process.exit(1); +} + +const files = packageFiles(); +const targetVersion = nextVersion(currentPublishedVersion(files), bump); + +for (const file of files) { + const fullPath = path.join(root, file); + const json = readJson(fullPath); + json.version = targetVersion; + writeJson(fullPath, json); + console.log(`${file}: ${targetVersion}`); +} + +run('pnpm', ['install', '--ignore-scripts', '--lockfile-only']); +run('node', ['./scripts/verify-release.mjs']); + +console.log(`Release version bumped to ${targetVersion}`); diff --git a/native/landlock-run/scripts/commit-release.mjs b/native/landlock-run/scripts/commit-release.mjs new file mode 100644 index 0000000000..b7bf3e513b --- /dev/null +++ b/native/landlock-run/scripts/commit-release.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Bump, stage, and commit a release in one command: + * `pnpm release:commit `. The tag stays manual — + * create it from the merged release commit. + */ + +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { packageDirs, readJson, root } from './repo.mjs'; + +const bump = process.argv[2]; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + stdio: 'inherit', + env: { ...process.env, CI: 'true' }, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (!bump) { + console.error('Usage: pnpm release:commit '); + process.exit(1); +} + +run('node', ['./scripts/bump-release.mjs', bump]); + +const version = readJson(path.join(root, packageDirs()[0], 'package.json')).version; +run('git', [ + 'add', + 'package.json', + 'packages/*/package.json', + 'pnpm-lock.yaml', +]); +run('git', ['commit', '-m', `release: ${version}`]); + +console.log(`Committed release ${version}. Create the tag manually: git tag v${version}`); diff --git a/native/landlock-run/scripts/github-matrix.mjs b/native/landlock-run/scripts/github-matrix.mjs new file mode 100644 index 0000000000..9566b89c8a --- /dev/null +++ b/native/landlock-run/scripts/github-matrix.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Derive the GitHub Actions matrices from the checked-in package matrix + * (`packages//prebuilds.json`). Single source: adding a platform + * package extends CI and Release without editing a workflow. + * + * node scripts/github-matrix.mjs ci → one leg per distinct platform + * node scripts/github-matrix.mjs release-prebuild → one leg per platform package + */ + +import path from 'node:path'; +import { platformDirs, readJson, root } from './repo.mjs'; + +/** GitHub runner per prebuilds.json `platform` value — native builders only, no cross toolchain. */ +const RUNNERS = { + 'linux-x64': 'ubuntu-24.04', + 'linux-arm64': 'ubuntu-24.04-arm', +}; + +function runnerFor(platform) { + const runner = RUNNERS[platform]; + if (!runner) { + throw new Error(`missing GitHub runner for platform: ${platform}`); + } + return runner; +} + +function platformManifests() { + return platformDirs().map((dir) => ({ + dir, + name: path.basename(dir), + prebuilds: readJson(path.join(root, dir, 'prebuilds.json')), + })); +} + +function ciMatrix() { + const platforms = [...new Set(platformManifests().map(({ prebuilds }) => prebuilds.platform))].sort(); + return { + include: platforms.map((platform) => ({ platform, runner: runnerFor(platform) })), + }; +} + +function releasePrebuildMatrix() { + return { + include: platformManifests().map(({ dir, name, prebuilds }) => ({ + platform: prebuilds.platform, + package: name, + dir, + runner: runnerFor(prebuilds.platform), + artifact: `prebuild-${name}`, + })), + }; +} + +const target = process.argv[2]; +const matrices = { + ci: ciMatrix, + 'release-prebuild': releasePrebuildMatrix, +}; + +if (!target || !matrices[target]) { + console.error(`Usage: node scripts/github-matrix.mjs <${Object.keys(matrices).join('|')}>`); + process.exit(1); +} + +process.stdout.write(JSON.stringify(matrices[target]())); diff --git a/native/landlock-run/scripts/pack-release.mjs b/native/landlock-run/scripts/pack-release.mjs new file mode 100644 index 0000000000..fbec0b610b --- /dev/null +++ b/native/landlock-run/scripts/pack-release.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +/** + * Pack every published package into release tarballs, in publish order + * (platform packages first, then the entries that optionally depend on + * them), and write `publish-order.txt` next to them. `pnpm pack` produces + * the EXACT bytes `pnpm publish` would upload and runs each package's + * `prepack` gate, so a missing binary or unbuilt `lib/` refuses here. + * + * Usage: `node scripts/pack-release.mjs [dest] [--current-platform-only]`. + * The flag packs only THIS host's platform package plus the entries — for + * per-architecture CI legs, where the other architecture's binary does not + * exist (the exact refusal its prepack gate exists for). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { entryDirs, platformDirs, readJson, root } from './repo.mjs'; + +const args = process.argv.slice(2); +const currentPlatformOnly = args.includes('--current-platform-only'); +const destination = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); + +function hostPlatformDirs() { + const hostPlatform = `${process.platform}-${process.arch}`; + return platformDirs().filter((dir) => readJson(path.join(root, dir, 'prebuilds.json')).platform === hostPlatform); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + stdio: 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function tarballName(manifest) { + if (manifest.name.startsWith('@')) { + return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`; + } + return `${manifest.name}-${manifest.version}.tgz`; +} + +fs.rmSync(destination, { recursive: true, force: true }); +fs.mkdirSync(destination, { recursive: true }); + +const dirs = [...(currentPlatformOnly ? hostPlatformDirs() : platformDirs()), ...entryDirs()]; +const platformSet = new Set(platformDirs()); +const publishOrder = []; +for (const dir of dirs) { + const manifest = readJson(path.join(root, dir, 'package.json')); + // Platform packages are packed with npm: pnpm pack (observed on 11.7.0) + // normalizes file modes and STRIPS the executable bit, which ships a + // launcher no consumer can spawn; npm pack preserves it. Platform packages + // have no dependencies by construction, so they need none of pnpm's + // workspace-protocol conversion — the entry packages do, and carry no + // executables, so they keep pnpm pack. + if (platformSet.has(dir)) { + run('npm', ['pack', `./${dir}`, '--pack-destination', destination]); + } else { + run('pnpm', ['--dir', dir, 'pack', '--pack-destination', destination]); + } + + const tarball = tarballName(manifest); + const tarballPath = path.join(destination, tarball); + if (!fs.existsSync(tarballPath)) { + throw new Error(`expected pack output not found: ${tarballPath}`); + } + publishOrder.push(tarball); +} + +fs.writeFileSync(path.join(destination, 'publish-order.txt'), `${publishOrder.join('\n')}\n`); +console.log(`Packed ${publishOrder.length} packages into ${path.relative(root, destination)}`); diff --git a/native/landlock-run/scripts/repo.mjs b/native/landlock-run/scripts/repo.mjs new file mode 100644 index 0000000000..8032d3da37 --- /dev/null +++ b/native/landlock-run/scripts/repo.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Shared helpers for the repo scripts: package discovery, the checked-in + * prebuild matrix, and binary verification. The package matrix is explicit + * metadata — `packages//prebuilds.json` marks a platform package and + * declares its binaries; everything else under `packages/` is an entry + * package. Scripts derive from these files and never guess. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const root = fileURLToPath(new URL('..', import.meta.url)); +export const packagesRoot = path.join(root, 'packages'); + +/** ELF `e_machine` (offset 18, little-endian) per platform-package `cpu` value. */ +export const E_MACHINE = { x64: 62, arm64: 183 }; + +export function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +/** Platform packages: every `packages/` carrying a `prebuilds.json`. */ +export function platformDirs() { + return fs.readdirSync(packagesRoot) + .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) + .sort() + .map((name) => path.join('packages', name)); +} + +/** Entry packages: every other `packages/` with a `package.json`. */ +export function entryDirs() { + return fs.readdirSync(packagesRoot) + .filter((name) => !fs.existsSync(path.join(packagesRoot, name, 'prebuilds.json'))) + .filter((name) => fs.existsSync(path.join(packagesRoot, name, 'package.json'))) + .sort() + .map((name) => path.join('packages', name)); +} + +/** All published packages in publish order: platform packages before the entries that optionally depend on them. */ +export function packageDirs() { + return [...platformDirs(), ...entryDirs()]; +} + +/** + * Verify one platform package's binaries against its `prebuilds.json`: + * every declared binary exists, nothing undeclared sits in `bin/`, and each + * file's ELF `e_machine` matches the package's declared `cpu`. Throws with + * a remediation message on the first mismatch. + */ +export function verifyPlatformBinaries(packageDir) { + const manifest = readJson(path.join(packageDir, 'package.json')); + const prebuilds = readJson(path.join(packageDir, 'prebuilds.json')); + const cpu = manifest.cpu?.[0]; + if (cpu === undefined || !(cpu in E_MACHINE)) { + throw new Error(`${manifest.name}: unsupported or missing "cpu" in package.json (expected one of: ${Object.keys(E_MACHINE).join(', ')})`); + } + + for (const binary of prebuilds.binaries) { + const file = path.join(packageDir, binary.path); + if (!fs.existsSync(file)) { + throw new Error(`${manifest.name}: missing ${binary.path} — run \`pnpm build:native\` on a ${prebuilds.platform} host (or assemble release artifacts) before packing.`); + } + try { + fs.accessSync(file, fs.constants.X_OK); + } catch { + // Only reachable when the mode was mangled somewhere between build and + // here (e.g. an archive step that normalized permissions) — the build + // itself always produces 755. + throw new Error(`${manifest.name}: ${binary.path} is not executable — a pack/extract step stripped the mode bit.`); + } + const machine = fs.readFileSync(file).readUInt16LE(18); + if (machine !== E_MACHINE[cpu]) { + throw new Error(`${manifest.name}: ${binary.path} has ELF e_machine ${machine}, expected ${E_MACHINE[cpu]} for ${cpu} — the binary was built for a different architecture.`); + } + } + + const declared = prebuilds.binaries.map((binary) => path.basename(binary.path)).sort(); + const binDir = path.join(packageDir, 'bin'); + const actual = fs.existsSync(binDir) ? fs.readdirSync(binDir).sort() : []; + const extra = actual.filter((name) => !declared.includes(name)); + if (extra.length) { + throw new Error(`${manifest.name}: bin/ contains files not declared in prebuilds.json: ${extra.join(', ')}`); + } + + return { name: manifest.name, count: prebuilds.binaries.length }; +} diff --git a/native/landlock-run/scripts/verify-entry-lib.mjs b/native/landlock-run/scripts/verify-entry-lib.mjs new file mode 100644 index 0000000000..214705e2bc --- /dev/null +++ b/native/landlock-run/scripts/verify-entry-lib.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Prepack gate for entry packages: refuse to pack a tarball whose built + * `lib/` is missing. Entry `files` lists use globs, and a glob matching + * nothing packs a silently JS-less tarball instead of failing — this gate + * turns that into a loud refusal on a checkout that never ran + * `pnpm build:ts`. + * + * Runs from each entry package's `prepack` hook (pnpm sets the script cwd + * to the package directory). + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const packageDir = process.cwd(); +const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + +for (const file of ['lib/index.js', 'lib/index.d.ts']) { + if (!fs.existsSync(path.join(packageDir, file))) { + console.error(`verify-entry-lib: ${manifest.name} has no ${file} — run \`pnpm build:ts\` before packing.`); + process.exit(1); + } +} +console.log(`verify-entry-lib: ${manifest.name} built lib/ present.`); diff --git a/native/landlock-run/scripts/verify-launcher-binary.mjs b/native/landlock-run/scripts/verify-launcher-binary.mjs new file mode 100644 index 0000000000..083cd837aa --- /dev/null +++ b/native/landlock-run/scripts/verify-launcher-binary.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Prepack gate for platform packages: refuse to pack a tarball whose + * declared binaries are missing or built for the wrong architecture. + * + * Without it, `pnpm pack` on a checkout that never ran + * `pnpm run build:native` would ship an EMPTY platform package — the + * binary's absence surfacing only at runtime as a failed probe on every + * consumer — and a binary copied across packages would advertise an + * architecture it cannot execute. The check is presence + ELF `e_machine` + * against the package's declared `cpu`; byte provenance is + * `verify-packed-install.mjs`'s concern (it pins the installed tarball + * against the workspace build). + * + * Runs from each platform package's `prepack` hook (pnpm sets the script + * cwd to the package directory). Also callable directly with an explicit + * package directory: `node scripts/verify-launcher-binary.mjs packages/`. + */ + +import path from 'node:path'; +import { root, verifyPlatformBinaries } from './repo.mjs'; + +const packageDir = process.argv[2] ? path.resolve(root, process.argv[2]) : process.cwd(); + +try { + const { name, count } = verifyPlatformBinaries(packageDir); + console.log(`verify-launcher-binary: ${name} — ${count} binaries present with the right ELF architecture.`); +} catch (error) { + console.error(`verify-launcher-binary: ${error instanceof Error ? error.message : error}`); + process.exit(1); +} diff --git a/native/landlock-run/scripts/verify-packed-install.mjs b/native/landlock-run/scripts/verify-packed-install.mjs new file mode 100644 index 0000000000..60f225a9d2 --- /dev/null +++ b/native/landlock-run/scripts/verify-packed-install.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Publish-path rehearsal without publishing: verify the packed tarballs are + * exactly what a consumer install needs. `pnpm pack` already produced the + * bytes `pnpm publish` would upload; this script checks the payload + * (coverage, concrete dependency versions, NO lifecycle install scripts — + * this family has no install fallback on purpose), unpacks the entry plus + * THIS host's platform tarball into a throwaway consumer OUTSIDE the repo, + * byte-pins the installed binary against the workspace build it was packed + * from, and drives the INSTALLED entry under plain `node` — resolution, + * probe, and a real confinement world-proof through the installed launcher. + * + * On non-Linux hosts (no platform package exists) it instead proves the + * documented degradation: resolution falls back to a nonexistent path and + * the probe reports `unusable`. + * + * Usage: `node scripts/verify-packed-install.mjs [tarball-dir] [--current-platform-only]`. + * The flag skips the all-platforms tarball-presence check for + * per-architecture CI legs. `NALR_REQUIRE_LANDLOCK=1` makes an unenforcing + * kernel a failure instead of a skipped world-proof (set on CI, where the + * kernel is known). + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { entryDirs, packageDirs, platformDirs, readJson, root } from './repo.mjs'; + +const args = process.argv.slice(2); +const currentPlatformOnly = args.includes('--current-platform-only'); +const tarballDir = path.resolve(args.find((arg) => !arg.startsWith('--')) || path.join(root, 'dist', 'npm')); +const entryPackageName = 'node-addon-landlock-run'; + +function tarballName(manifest) { + if (manifest.name.startsWith('@')) { + return `${manifest.name.slice(1).replace('/', '-')}-${manifest.version}.tgz`; + } + return `${manifest.name}-${manifest.version}.tgz`; +} + +function tarballPath(manifest) { + const tarball = path.join(tarballDir, tarballName(manifest)); + if (!fs.existsSync(tarball)) { + throw new Error(`missing packed tarball: ${tarball}`); + } + return tarball; +} + +function run(command, commandArgs, options = {}) { + const result = spawnSync(command, commandArgs, { + cwd: options.cwd || root, + stdio: 'inherit', + env: { ...process.env, ...options.env }, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function runCapture(command, commandArgs) { + const result = spawnSync(command, commandArgs, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + return result.stdout; +} + +function readPackedManifest(manifest) { + return JSON.parse(runCapture('tar', ['-xOf', tarballPath(manifest), 'package/package.json'])); +} + +function verifyPackedManifest(packed) { + const lifecycle = ['preinstall', 'install', 'postinstall', 'prepare']; + for (const script of lifecycle) { + if (packed.scripts?.[script]) { + throw new Error(`${packed.name}: packed manifest carries a "${script}" lifecycle script — this family has no install fallback`); + } + } + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, version] of Object.entries(packed[field] ?? {})) { + if (version.includes('workspace:')) { + throw new Error(`${packed.name}: packed ${field} still uses the workspace protocol: ${name}@${version}`); + } + } + } +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function packageInstallDir(packageName) { + return path.join(tempRoot, 'node_modules', ...packageName.split('/')); +} + +function unpackTarball(manifest) { + const extractRoot = fs.mkdtempSync(path.join(tempRoot, 'extract-')); + run('tar', ['-xzf', tarballPath(manifest), '-C', extractRoot]); + + const source = path.join(extractRoot, 'package'); + const destination = packageInstallDir(manifest.name); + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.renameSync(source, destination); + fs.rmSync(extractRoot, { recursive: true, force: true }); + console.log(`Unpacked ${manifest.name} -> ${path.relative(tempRoot, destination)}`); +} + +const manifests = packageDirs().map((dir) => ({ dir, manifest: readJson(path.join(root, dir, 'package.json')) })); +const entryManifest = manifests.find(({ manifest }) => manifest.name === entryPackageName)?.manifest; +if (!entryManifest) throw new Error(`missing source manifest for ${entryPackageName}`); + +const hostPlatform = `${process.platform}-${process.arch}`; +const currentPlatformEntry = manifests.find( + ({ dir, manifest }) => platformDirs().includes(dir) && manifest.name === `${entryPackageName}-${hostPlatform}`, +); + +// Payload checks: every expected tarball exists (full mode), the packed +// entry's optional-dependency set names exactly the platform packages, and +// no packed manifest carries workspace versions or install lifecycle. +const expectedTarballs = currentPlatformOnly + ? manifests.filter(({ dir }) => entryDirs().includes(dir) || dir === currentPlatformEntry?.dir) + : manifests; +for (const { manifest } of expectedTarballs) { + tarballPath(manifest); +} + +const packedEntry = readPackedManifest(entryManifest); +const platformPackageNames = manifests + .filter(({ dir }) => platformDirs().includes(dir)) + .map(({ manifest }) => manifest.name) + .sort(); +const optionalNames = Object.keys(packedEntry.optionalDependencies || {}).sort(); +if (optionalNames.join('\n') !== platformPackageNames.join('\n')) { + throw new Error(`packed entry optionalDependencies mismatch\nactual:\n${optionalNames.join('\n')}\nexpected:\n${platformPackageNames.join('\n')}`); +} +for (const { manifest } of expectedTarballs) { + verifyPackedManifest(readPackedManifest(manifest)); +} + +// Throwaway ESM consumer, built from local tarballs only — no registry. +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-packed-install-')); +fs.writeFileSync( + path.join(tempRoot, 'package.json'), + `${JSON.stringify({ name: 'nalr-packed-install-check', version: '0.0.0', private: true, type: 'module' }, null, 2)}\n`, +); +console.log(`Verifying packed install in ${tempRoot}`); + +unpackTarball(entryManifest); +if (currentPlatformEntry) { + unpackTarball(currentPlatformEntry.manifest); + + // Byte-pin: the installed binary must be the workspace build it was packed + // from — any divergence means the tarball did not carry the built bytes. + const prebuilds = readJson(path.join(root, currentPlatformEntry.dir, 'prebuilds.json')); + for (const binary of prebuilds.binaries) { + const workspaceFile = path.join(root, currentPlatformEntry.dir, binary.path); + const installedFile = path.join(packageInstallDir(currentPlatformEntry.manifest.name), binary.path); + if (sha256(workspaceFile) !== sha256(installedFile)) { + throw new Error(`installed ${binary.path} differs from the workspace build it was packed from`); + } + console.log(`Byte-pinned ${binary.path} against the workspace build`); + } +} else if (process.platform === 'linux') { + throw new Error(`linux host without a platform package in the matrix: ${hostPlatform}`); +} + +// Drive the INSTALLED entry under plain node: resolution, probe, and (on an +// enforcing kernel) a real confinement world-proof through the installed +// launcher. +const driver = path.join(tempRoot, 'driver.mjs'); +fs.writeFileSync(driver, ` +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; + +const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; +const platformPackage = 'node-addon-landlock-run-' + process.platform + '-' + process.arch; +const resolved = launcherPath(); +assert.ok(path.isAbsolute(resolved), 'launcherPath must be absolute'); +assert.ok(resolved.includes(path.join(...platformPackage.split('/'))), 'launcherPath must point into the platform package: ' + resolved); + +if (process.platform === 'linux') { + assert.ok(fs.existsSync(resolved), 'installed launcher missing at ' + resolved); + try { + fs.accessSync(resolved, fs.constants.X_OK); + } catch { + throw new Error('installed launcher is not executable — the pack path stripped the mode bit: ' + resolved); + } + const enforcement = probe(resolved); + console.log('probe through the installed launcher: ' + enforcement); + if (enforcement === 'unusable') { + if (requireLandlock) throw new Error('NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable'); + console.log('kernel does not enforce Landlock — skipping the confinement world-proof'); + } else { + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-confine-')); + const denied = path.join(work, 'denied.txt'); + const deniedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo x > ' + denied], { encoding: 'utf8' }); + assert.notEqual(deniedRun.status, 0, 'write outside the grants must fail'); + assert.ok(!fs.existsSync(denied), 'denied write must not land on disk'); + const granted = path.join(work, 'granted.txt'); + const grantedRun = spawnSync(resolved, [...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', 'echo ok > ' + granted], { encoding: 'utf8' }); + assert.equal(grantedRun.status, 0, 'granted write must succeed: ' + grantedRun.stderr); + assert.equal(fs.readFileSync(granted, 'utf8').trim(), 'ok'); + console.log('confinement world-proof passed through the installed launcher'); + } +} else { + assert.ok(!fs.existsSync(resolved), 'no platform package exists for this host — the fallback path must not exist'); + assert.equal(probe(resolved), 'unusable'); + console.log('non-linux host: fallback resolution and unusable probe verified'); +} +`); +run(process.execPath, [driver], { cwd: tempRoot }); + +console.log('Packed install verification passed.'); diff --git a/native/landlock-run/scripts/verify-release.mjs b/native/landlock-run/scripts/verify-release.mjs new file mode 100644 index 0000000000..e812b34a14 --- /dev/null +++ b/native/landlock-run/scripts/verify-release.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Release verification. Always: every published package carries one shared + * version, and — when running from a tag or publishing — the `vX.Y.Z` tag + * matches it. With `--prebuilds`: every platform package's declared + * binaries exist with the right ELF architecture (run after + * `assemble-prebuilds.mjs` or a local `build:native`). + */ + +import path from 'node:path'; +import { packageDirs, platformDirs, readJson, root, verifyPlatformBinaries } from './repo.mjs'; + +function verifyVersions() { + const packages = packageDirs().map((dir) => ({ + dir, + manifest: readJson(path.join(root, dir, 'package.json')), + })); + const versions = new Set(packages.map((pkg) => pkg.manifest.version)); + if (versions.size !== 1) { + throw new Error([ + 'published package versions must match:', + ...packages.map((pkg) => `${pkg.dir}: ${pkg.manifest.version}`), + ].join('\n')); + } + + const version = packages[0].manifest.version; + const ref = process.env.GITHUB_REF || ''; + const publish = process.env.RELEASE_PUBLISH === 'true'; + if (publish && !ref.startsWith('refs/tags/v')) { + throw new Error('publishing requires running the workflow from a v* tag'); + } + if (ref.startsWith('refs/tags/v')) { + const tagVersion = ref.slice('refs/tags/v'.length); + if (tagVersion !== version) { + throw new Error(`tag/version mismatch: tag v${tagVersion}, packages ${version}`); + } + } + + console.log(`Verified release version ${version}`); +} + +function verifyPrebuilds() { + for (const dir of platformDirs()) { + const { name, count } = verifyPlatformBinaries(path.join(root, dir)); + console.log(`Verified ${name}: ${count} binaries`); + } +} + +verifyVersions(); +if (process.argv.includes('--prebuilds')) { + verifyPrebuilds(); +} diff --git a/native/landlock-run/test/entry.test.js b/native/landlock-run/test/entry.test.js new file mode 100644 index 0000000000..2e2cfe8f17 --- /dev/null +++ b/native/landlock-run/test/entry.test.js @@ -0,0 +1,76 @@ +/** + * Keyless entry-package tests — run on every host, no kernel or binary + * required. Cover the JS seam's pure surface: grant-argv construction, the + * resolution contract (platform package → fallback), and probe verdicts over + * fake launchers. Requires built `lib/` (`pnpm build:ts`). + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + LAUNCHER_BIN, + LAUNCHER_FAILURE_EXIT, + grantArgs, + launcherPath, + probe, +} from 'node-addon-landlock-run'; + +// --- constants are part of the CLI contract --- +assert.equal(LAUNCHER_BIN, 'landlock-run'); +assert.equal(LAUNCHER_FAILURE_EXIT, 125); + +// --- grantArgs: flag spelling, ordering, and empty grants --- +assert.deepEqual(grantArgs({}), []); +assert.deepEqual(grantArgs({ readOnly: ['/'] }), ['--ro', '/']); +assert.deepEqual( + grantArgs({ readOnly: ['/', '/opt'], readWrite: ['/tmp/work'] }), + ['--ro', '/', '--ro', '/opt', '--rw', '/tmp/work'], +); +assert.deepEqual(grantArgs({ readWrite: ['/a'], readOnly: ['/b'] }), ['--ro', '/b', '--rw', '/a']); + +// --- launcherPath: resolves the platform package next to its package.json --- +const platformPackage = `node-addon-landlock-run-${process.platform}-${process.arch}`; +const resolvedViaSeam = launcherPath((specifier) => { + assert.equal(specifier, `${platformPackage}/package.json`); + return path.join('/fake-install', specifier); +}); +assert.equal(resolvedViaSeam, path.join('/fake-install', platformPackage, 'bin', LAUNCHER_BIN)); + +// --- launcherPath: unresolvable package falls back to an absolute, package-boundary path --- +const fallback = launcherPath(() => { + throw new Error('not installed'); +}); +assert.ok(path.isAbsolute(fallback), 'fallback path must be absolute'); +assert.ok( + fallback.includes(path.join('node_modules', ...platformPackage.split('/'), 'bin', LAUNCHER_BIN)), + `fallback must point at the platform package layout: ${fallback}`, +); + +// --- launcherPath: default resolution agrees with this workspace's layout --- +const defaultPath = launcherPath(); +assert.ok(path.isAbsolute(defaultPath)); +assert.ok(defaultPath.endsWith(path.join('bin', LAUNCHER_BIN)), defaultPath); + +// --- probe: a missing launcher is unusable, indistinguishable from an unenforcing kernel --- +assert.equal(probe(path.join(os.tmpdir(), 'nalr-no-such-launcher')), 'unusable'); + +// --- probe: verdict parsing over fake launchers (POSIX shells only) --- +if (process.platform !== 'win32') { + const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-fake-launcher-')); + const fake = (name, script) => { + const file = path.join(fakeDir, name); + fs.writeFileSync(file, `#!/bin/sh\n${script}\n`, { mode: 0o755 }); + return file; + }; + + assert.equal(probe(fake('full', 'echo "landlock: fully enforced"; exit 0')), 'full'); + assert.equal(probe(fake('partial', 'echo "landlock: partially enforced (older ABI)"; exit 0')), 'partial'); + assert.equal(probe(fake('failing', `exit ${LAUNCHER_FAILURE_EXIT}`)), 'unusable'); + assert.equal(probe(fake('hanging', 'sleep 10'), { timeoutMs: 200 }), 'unusable'); + + fs.rmSync(fakeDir, { recursive: true, force: true }); +} + +console.log('entry.test: ok'); diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js new file mode 100644 index 0000000000..4f456e2145 --- /dev/null +++ b/native/landlock-run/test/launcher.test.js @@ -0,0 +1,121 @@ +/** + * Behavioral tests against the REAL launcher binary on a real kernel: the + * CLI contract (usage errors, exit codes, argv passthrough) and the + * confinement world-proofs (denied writes stay off disk, grants land). + * + * Preconditions and their skip semantics: + * - Non-Linux host: skips entirely (exit 0) — there is nothing to build here. + * - Linux without the built binary: FAILS — run `pnpm build:native` first. + * - Linux whose kernel does not enforce Landlock: skips the enforcement + * half, unless `NALR_REQUIRE_LANDLOCK=1` (set on CI, where a silent skip on + * the very platform that exists to prove enforcement would be a false + * green). + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + LAUNCHER_FAILURE_EXIT, + grantArgs, + launcherPath, + probe, +} from 'node-addon-landlock-run'; + +const requireLandlock = process.env.NALR_REQUIRE_LANDLOCK === '1'; + +if (process.platform !== 'linux') { + console.log(`launcher.test: SKIP — the launcher only exists on linux (host: ${process.platform})`); + process.exit(0); +} + +const launcher = launcherPath(); +assert.ok( + fs.existsSync(launcher), + `launcher.test: no built launcher at ${launcher} — run \`pnpm build:native\` (apt-get install musl-tools) first`, +); + +const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8', ...options }); + +// --- usage errors: parse failures exit LAUNCHER_FAILURE_EXIT before any restriction --- +{ + const noCommand = run([]); + assert.equal(noCommand.status, LAUNCHER_FAILURE_EXIT); + assert.match(noCommand.stderr, /usage error: missing `-- \.\.\.` command/); + + const unknownFlag = run(['--bogus', '--', 'true']); + assert.equal(unknownFlag.status, LAUNCHER_FAILURE_EXIT); + assert.match(unknownFlag.stderr, /usage error: unknown argument: --bogus/); + + const danglingPath = run(['--ro']); + assert.equal(danglingPath.status, LAUNCHER_FAILURE_EXIT); + assert.match(danglingPath.stderr, /--ro requires a path/); + + const probeWithExtras = run(['--probe', '--ro', '/']); + assert.equal(probeWithExtras.status, LAUNCHER_FAILURE_EXIT); + assert.match(probeWithExtras.stderr, /--probe takes no other arguments/); +} + +// --- probe: the functional availability signal --- +const enforcement = probe(launcher); +console.log(`launcher.test: probe → ${enforcement}`); +if (enforcement === 'unusable') { + if (requireLandlock) { + console.error('launcher.test: NALR_REQUIRE_LANDLOCK=1 but the probe reports unusable — this kernel cannot prove enforcement'); + process.exit(1); + } + console.log('launcher.test: SKIP enforcement half — kernel does not enforce Landlock'); + process.exit(0); +} +{ + const probeRun = run(['--probe']); + assert.equal(probeRun.status, 0); + assert.match(probeRun.stdout, /^landlock: (fully enforced|partially enforced \(older ABI\))\n$/); +} + +// --- confined exec: the command runs, its exit code passes through --- +{ + const echo = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'echo confined-ok']); + assert.equal(echo.status, 0, echo.stderr); + assert.equal(echo.stdout, 'confined-ok\n'); + + const exitCode = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', 'exit 7']); + assert.equal(exitCode.status, 7, 'the wrapped command exit code must pass through unchanged'); +} + +// --- world-proofs: denied writes stay off disk, grants land, inheritance crosses exec --- +{ + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'nalr-launcher-test-')); + + const denied = path.join(work, 'denied.txt'); + const deniedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `echo x > ${denied}`]); + assert.notEqual(deniedRun.status, 0, 'a write outside the grants must fail'); + assert.ok(!fs.existsSync(denied), 'the denied write must not land on disk'); + + const granted = path.join(work, 'granted.txt'); + const grantedRun = run([...grantArgs({ readOnly: ['/'], readWrite: [work] }), '--', '/bin/sh', '-c', `echo ok > ${granted}`]); + assert.equal(grantedRun.status, 0, grantedRun.stderr); + assert.equal(fs.readFileSync(granted, 'utf8'), 'ok\n'); + + // The ruleset is inherited across execve: a CHILD of the wrapped command + // is confined too, not just the direct exec target. + const nested = path.join(work, 'nested.txt'); + const nestedRun = run([...grantArgs({ readOnly: ['/'] }), '--', '/bin/sh', '-c', `/bin/sh -c 'echo x > ${nested}'; true`]); + assert.equal(nestedRun.status, 0, nestedRun.stderr); + assert.ok(!fs.existsSync(nested), 'a denied write from a nested child must not land either'); + + fs.rmSync(work, { recursive: true, force: true }); +} + +// --- fail closed: an unopenable grant root refuses to exec at all --- +{ + const marker = path.join(os.tmpdir(), `nalr-should-not-exist-${process.pid}`); + const badGrant = run(['--ro', '/no/such/grant/root', '--', '/bin/sh', '-c', `echo x > ${marker}`]); + assert.equal(badGrant.status, LAUNCHER_FAILURE_EXIT); + assert.match(badGrant.stderr, /cannot open rule path/); + assert.ok(!fs.existsSync(marker), 'the command must never run when the launcher fails'); +} + +console.log('launcher.test: ok'); diff --git a/native/landlock-run/tsconfig.base.json b/native/landlock-run/tsconfig.base.json new file mode 100644 index 0000000000..a95ca64f6d --- /dev/null +++ b/native/landlock-run/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + } +} diff --git a/native/landlock-run/tsconfig.json b/native/landlock-run/tsconfig.json new file mode 100644 index 0000000000..3813d343cd --- /dev/null +++ b/native/landlock-run/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "files": [], + "include": ["scripts/**/*.ts"], + "references": [ + { "path": "./packages/entry" } + ] +} diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a03f7ca25a..1c75d8e90f 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1370, + "AGENTS.md": 1375, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, "docs/cordis-primer.md": 550, From e723f233fb090fd2743f8f0dcca9db93aaf1dcbd Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 14 Jul 2026 23:41:27 +0800 Subject: [PATCH 12/88] 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 13/88] 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 6eddf38a77d1e88c52530c64555124e1fb47788f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:42:32 +0800 Subject: [PATCH 14/88] feat(acp): enable automatic compaction --- examples/acp-agent/composition.md | 3 +++ examples/acp-agent/cordis.yml | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b93b4cb1e2..1dc297a5fc 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -27,6 +27,8 @@ flowchart LR 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_acp_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_acp_compact_basic plugin_acp_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_acp_subagent plugin_acp_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] @@ -59,6 +61,7 @@ flowchart LR | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | | `acp-agent` | `@deepseek-ai/dsh-acp-agent` | +| `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` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 201feafe41..17ed222660 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -48,6 +48,18 @@ Verify your work by running the code or tests. Keep answers brief and factual. +# Summarize an older range when derived history approaches the context window. +# This leaf consumes `ctx.llm` and the app's `agent/pre-step` seam. +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 + # Expose fresh-child `spawn` and completed-prefix `fork` through separate tool # names so multi-child scenarios exercise both transports. These leaves follow # the app because it provides `ctx.agents` and `ctx.tools`. From 5ca3eab6ceb522c5a69bb198af7d71316f87a639 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:46:43 +0800 Subject: [PATCH 15/88] fix(acp): use 256k compaction window --- examples/acp-agent/cordis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 17ed222660..1e78892113 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -53,7 +53,7 @@ - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 + contextWindow: 256000 thresholdRatio: 0.8 retainTokens: 20480 summarizationModel: '' From 6af61c6f4e89dedeec5b60886eed16144a37cf10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 15 Jul 2026 18:08:28 +0800 Subject: [PATCH 16/88] 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 ce96104a774120c72097d8a89755ec1eb3cfb001 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:02:15 +0800 Subject: [PATCH 17/88] 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 18/88] 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 ba4a57c7d19c3119f1f3876a8472b758902ddc9d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:51:26 +0800 Subject: [PATCH 19/88] 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 20/88] 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 21/88] 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 22/88] 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 23/88] 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 24/88] 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 25/88] 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 26/88] 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 27/88] 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 28/88] 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 29/88] 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 30/88] 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 31/88] 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 32/88] 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 d49808c6dd60c24e05199c375a1483898de0d9fb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 20:14:32 +0800 Subject: [PATCH 33/88] 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 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 34/88] 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 35/88] 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 b1b57a0ac54538a7dc4753a31c30cbbc474643c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:28:30 +0800 Subject: [PATCH 36/88] Require Agent Notes for non-trivial changes --- .agents/notes/README.md | 4 +-- ...nt-notes-for-non-trivial-changes.i18n.yaml | 6 ++++ ...ire-agent-notes-for-non-trivial-changes.md | 31 +++++++++++++++++++ ...-agent-notes-for-non-trivial-changes.zh.md | 31 +++++++++++++++++++ AGENTS.md | 4 +-- docs/AGENTS.md | 2 +- 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md create mode 100644 .agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 79aa2fb8d4..e62dc18954 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -33,9 +33,9 @@ The `architecture` / `process` line: **architecture** is about the source we shi ## When to write one -Write an Agent Note when a decision is **durable** (it shapes the codebase beyond a single function or package), **contested** (there was a real alternative a reasonable engineer might have chosen), and **surprising** (a future reader would otherwise ask "why on earth is it done this way?"). A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Do NOT write one for a mechanical or local choice (a variable name, a one-file refactor), for anything already enforced and explained by a gate or a convention in AGENTS.md, or for a still-provisional decision tagged `TODO(...)` in the code — record those as TODOs and promote to an Agent Note only once they settle. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. (Editing an `implemented/` Agent Note to track where its already-made decision now *lives* — a moved file, a renamed package — is not a different decision and is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).) +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). ## The file format diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml new file mode 100644 index 0000000000..ae5ed9b11e --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.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-require-agent-notes-for-non-trivial-changes.md: f2645832ebcdd0b81cbff5415c7eb6f60b6fa8cf +2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 659aa7cad0823fa0082be1827f8c083037376a4c diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md new file mode 100644 index 0000000000..f2645832eb --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md @@ -0,0 +1,31 @@ +# Agent Note: Require an Agent Note for every non-trivial change + +Status: implemented + +English | [中文](2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md) + +## Problem + +A selective threshold based on whether a decision seems durable, contested, and surprising lets substantial changes land without preserving their rationale. Code and tests show what changed, but they cannot consistently preserve why an approach won, which alternatives lost, or what costs maintainers accepted. + +## Decision + +Every non-trivial change adds or updates at least one Agent Note in the same PR. Non-trivial changes include behavior, architecture, cross-file or cross-package contracts, process or tooling, testing strategy, on-disk, wire, or configuration formats, and other decisions a maintainer may reasonably revisit. + +Updating the note that already owns a decision satisfies the rule; a new note is required only when no note owns it. Purely mechanical or local edits with no behavioral, contractual, structural, process, or rationale change are exempt. The [Agent Notes README](../../README.md#when-to-write-one) owns this boundary, while root `AGENTS.md` carries the standing order. + +Review enforces the semantic boundary. No automated gate attempts to classify a diff as trivial or non-trivial, so this policy adds no gate stage or runtime. + +## Alternatives considered + +**Require notes only for decisions judged durable, contested, and surprising.** The threshold is subjective enough that a substantial change can be treated as obvious or local, losing the rationale Agent Notes exist to preserve. + +**Require a new note for every change.** This duplicates an existing note when it already owns the decision and adds empty ceremony to purely mechanical edits. + +**Add a CI diff-classification gate.** A mechanical check cannot reliably determine whether a semantic change is trivial, while another gate adds runtime and invites false positives or superficial compliance. + +## Consequences + +- Every substantial change preserves its rationale and rejected alternatives beside the implementation. +- Contributors maintain an existing owning note instead of creating duplicate records. +- Mechanical edits remain lightweight, and the gate topology and runtime remain unchanged. diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md new file mode 100644 index 0000000000..659aa7cad0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 每项实质性变更都必须附带 Agent Note + +Status: implemented + +[English](2026-07-19-require-agent-notes-for-non-trivial-changes.md) | 中文 + +## 问题 + +如果只在决策被认为持久、有争议且出人意料时才记录 Agent Note,实质性变更就可能在没有保存决策依据的情况下落地。代码和测试能展示改动内容,却无法稳定保留某种方案胜出的原因、被放弃的备选方案,以及维护者接受的成本。 + +## 决策 + +每项实质性变更都在同一个 PR 中新增或更新至少一份 Agent Note。实质性变更包括行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘格式、线协议或配置格式,以及维护者可能合理重审的其他决策。 + +更新已经持有该决策的 Agent Note 即满足规则;仅当没有 Agent Note 持有该决策时才新增记录。完全机械或局部、且不改变行为、契约、结构、流程或决策依据的编辑可豁免。[Agent Notes README](../../README.md#when-to-write-one) 持有这条边界,根目录 `AGENTS.md` 则携带常驻指令。 + +评审负责执行这条语义边界。自动化门禁不尝试把差异分类为平凡或实质性变更,因此这项政策不会增加门禁阶段或运行时间。 + +## 备选方案 + +**只为被判断为持久、有争议且出人意料的决策要求 Agent Note。** 这条门槛过于主观,实质性变更可能被视为显而易见或局部改动,从而丢失 Agent Note 本应保存的决策依据。 + +**每项变更都必须新增 Agent Note。** 当现有 Agent Note 已经持有该决策时,这会产生重复记录,也会让纯机械编辑承担空洞的流程负担。 + +**添加 CI 差异分类门禁。** 机械检查无法可靠判断语义变更是否平凡,额外门禁还会增加运行时间,并引入误报或表面合规。 + +## 影响 + +- 每项实质性变更都会在实现旁保留其决策依据和被放弃的备选方案。 +- 贡献者维护现有的决策持有记录,而不是创建重复记录。 +- 机械编辑仍保持轻量,门禁拓扑和运行时间也保持不变。 diff --git a/AGENTS.md b/AGENTS.md index 7ceeb69dd2..341f8ed23a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **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. -- **Validate Agent Note premises against current code**; friction may expose overreach, so amend proposals before moving them to `implemented/`. +- **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. - **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. @@ -135,7 +135,7 @@ Everything compiles under `strict: true` with `noImplicitAny`; every remaining ` Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each new or changed acceptance path rejects an invalid case. Use narrow justified exceptions instead of disabling a rule globally. -Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README/JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 010fb126b7..a33e9738fc 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -25,7 +25,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems. -- **Write an Agent Note in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](../.agents/notes/README.md)). +- **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). 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 37/88] 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 38/88] 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 39/88] 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 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 40/88] 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 3a20b4c551016f048a1eb23dd7d50e933641589d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 10:48:58 +0800 Subject: [PATCH 41/88] 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 42/88] 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 4d037613b1045e3c458f8a7524071056c660898f Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:31:27 +0800 Subject: [PATCH 43/88] 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 ee1af10c52386d700fca3e9322802f48c34b66cf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:38:55 +0800 Subject: [PATCH 44/88] docs(acp): note per-model compaction config --- examples/acp-agent/cordis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index afecf01853..1b6af5c4f5 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -53,6 +53,7 @@ - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: + # FIXME: Resolve compaction config per model; these values assume a 256k context window. contextWindow: 256000 thresholdRatio: 0.8 retainTokens: 20480 From 33fadcf08d6ffb1bd7a39df4afaeefaa142f1fd9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:46:16 +0800 Subject: [PATCH 45/88] 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 247526d4cacda04b29f9629676e2fd74931b8b6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:47:17 +0800 Subject: [PATCH 46/88] fix(acp): adapt compaction wiring to token meter --- examples/acp-agent/composition.md | 3 +++ examples/acp-agent/cordis.yml | 14 +++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 86a31124fb..7f016e685c 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -27,6 +27,8 @@ flowchart LR 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_acp_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_acp_token_meter plugin_acp_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_acp_compact_basic plugin_acp_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -61,6 +63,7 @@ flowchart LR | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `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 05eaaa7d89..510fac46b6 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -48,16 +48,20 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Summarize an older range when derived history approaches the context window. -# This leaf consumes `ctx.llm` and the app's `agent/pre-step` seam. +# Replay-aware request pressure with one service-wide context window. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + config: + # FIXME: Resolve compaction config per model; this capacity assumes a 256k context window. + contextWindow: 256000 + +# 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' config: - # FIXME: Resolve compaction config per model; these values assume a 256k context window. - contextWindow: 256000 thresholdRatio: 0.8 retainTokens: 20480 - summarizationModel: '' maxTokens: 8192 compactionRetries: 1 From a6e96c47f37e12e1cb1a3a36514ba6aa060fe09e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 11:53:49 +0800 Subject: [PATCH 47/88] 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 4ce2202721febe40056046a3f58b8a8c04dbc4aa Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:56:08 +0800 Subject: [PATCH 48/88] 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 801289adf2dcb4345031c5c112296f6d376478c4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:25:36 +0800 Subject: [PATCH 49/88] fix(landlock-run): enforce probe CLI contract --- native/landlock-run/packages/entry/src/main.c | 9 ++++----- native/landlock-run/test/launcher.test.js | 12 +++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/native/landlock-run/packages/entry/src/main.c b/native/landlock-run/packages/entry/src/main.c index 2535f8bc31..af3c2eb3f0 100644 --- a/native/landlock-run/packages/entry/src/main.c +++ b/native/landlock-run/packages/entry/src/main.c @@ -155,6 +155,9 @@ static int parse(int argc, char **argv, struct cli *cli) { while (index < argc) { const char *arg = argv[index]; if (strcmp(arg, "--probe") == 0) { + if (argc != 2) { + return fail_usage("--probe takes no other arguments", NULL); + } cli->probe = 1; index += 1; } else if (strcmp(arg, "--ro") == 0 || strcmp(arg, "--rw") == 0) { @@ -174,11 +177,7 @@ static int parse(int argc, char **argv, struct cli *cli) { return fail_usage("unknown argument: ", arg); } } - if (cli->probe) { - if (cli->ro_count > 0 || cli->rw_count > 0 || (cli->command != NULL && cli->command[0] != NULL)) { - return fail_usage("--probe takes no other arguments", NULL); - } - } else if (cli->command == NULL || cli->command[0] == NULL) { + if (!cli->probe && (cli->command == NULL || cli->command[0] == NULL)) { return fail_usage("missing `-- ...` command", NULL); } return 0; diff --git a/native/landlock-run/test/launcher.test.js b/native/landlock-run/test/launcher.test.js index 4f456e2145..4ab0070e1c 100644 --- a/native/landlock-run/test/launcher.test.js +++ b/native/landlock-run/test/launcher.test.js @@ -53,9 +53,15 @@ const run = (args, options = {}) => spawnSync(launcher, args, { encoding: 'utf8' assert.equal(danglingPath.status, LAUNCHER_FAILURE_EXIT); assert.match(danglingPath.stderr, /--ro requires a path/); - const probeWithExtras = run(['--probe', '--ro', '/']); - assert.equal(probeWithExtras.status, LAUNCHER_FAILURE_EXIT); - assert.match(probeWithExtras.stderr, /--probe takes no other arguments/); + for (const args of [ + ['--probe', '--ro', '/'], + ['--probe', '--'], + ['--probe', '--probe'], + ]) { + const probeWithExtras = run(args); + assert.equal(probeWithExtras.status, LAUNCHER_FAILURE_EXIT); + assert.match(probeWithExtras.stderr, /--probe takes no other arguments/); + } } // --- probe: the functional availability signal --- From a3f792ea9fb4c86be26d89c1441d78a20b7e7c0b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:25:43 +0800 Subject: [PATCH 50/88] docs(landlock-run): align source and release guidance --- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 4 +--- native/landlock-run/docs/release.md | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 32b53d77ea..0f87885952 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -62,9 +62,7 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. -The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. - -FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. +The Landlock launcher source and package workspace live at `native/landlock-run`, next to the harness consumers. The standalone [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) repository is the release mirror used to pack and publish the npm package family; `native/README.md` owns the export procedure. Platform binaries are selected by npm, and the entry package owns path resolution, probing, and CLI flags while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. diff --git a/native/landlock-run/docs/release.md b/native/landlock-run/docs/release.md index e43b2d188c..e1ea65c411 100644 --- a/native/landlock-run/docs/release.md +++ b/native/landlock-run/docs/release.md @@ -25,13 +25,14 @@ git tag v0.0.2 pnpm install --frozen-lockfile pnpm build:ts pnpm typecheck -pnpm test # launcher half needs a Linux host with the binary built +pnpm test:entry ``` On a Linux host, also rehearse the pack path locally: ```sh pnpm build:native +pnpm test:launcher node ./scripts/pack-release.mjs .release/npm --current-platform-only node ./scripts/verify-packed-install.mjs .release/npm --current-platform-only ``` From aeb48cc5f3123c22d202763c91544f53a9492b45 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:17:09 +0800 Subject: [PATCH 51/88] 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 52/88] 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 da70e4704856542ab7b2fab0f51dde07e35a1743 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:15:11 +0800 Subject: [PATCH 53/88] 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 54/88] 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 2530bf8aa3bc3fca2fb3a4e2ade0494e556ab1f2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 20 Jul 2026 13:59:18 +0800 Subject: [PATCH 55/88] =?UTF-8?q?fix(sandbox):=20address=20PR=20#309=20rev?= =?UTF-8?q?iew=20=E2=80=94=20TOCTOU=20direction,=20denial=20metadata,=20sh?= =?UTF-8?q?ared=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 56/88] 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 57/88] 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 58/88] 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 59/88] 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 60/88] 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 61/88] 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 62/88] 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 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 63/88] 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 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 64/88] 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 65/88] 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 66/88] 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 67/88] 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 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 68/88] 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 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 69/88] 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 70/88] 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 71/88] 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 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 72/88] 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 73/88] 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 74/88] 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 75/88] 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 76/88] 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 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 77/88] 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 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 78/88] 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 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 79/88] 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 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 80/88] 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 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 81/88] 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 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 82/88] 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 83/88] 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 84/88] 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 85/88] 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 86/88] 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 87/88] 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 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 88/88] 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)', () => {