diff --git a/.agents/notes/AGENTS.md b/.agents/notes/AGENTS.md index ea0fa8f42c..e8e1a0ef66 100644 --- a/.agents/notes/AGENTS.md +++ b/.agents/notes/AGENTS.md @@ -2,4 +2,6 @@ Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md). +**Every new Agent Note triggers a supersession check.** Search the active tree for older notes covering the same decision or mechanism, classify any full or partial supersession with [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md), and archive every qualifying implemented triplet in the same PR. Keep partial supersessions active and cross-linked. + Files under [`archived/`](archived/AGENTS.md) are frozen historical snapshots: never edit them or treat them as current authority. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 1c6f4e06ac..0d6a23bebe 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -1,6 +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-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5 -2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +2026-06-21-bounded-llm-request-recovery.md: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed +2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4 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 22a56dc6d6..83d47e3a7d 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 @@ -4,11 +4,13 @@ Status: implemented English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. + ## 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. +`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. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract. -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. +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 turn from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. The prior boundary left three narrower gaps. @@ -16,7 +18,7 @@ The prior boundary left three narrower gaps. - 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. +The default policy provides 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. ## Decision @@ -50,31 +52,19 @@ The shared transient-code set is intentionally small: adapter mappings for `RATE `@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. -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 `agent/request-error` seam carries the current `LlmFailure`, an immutable list of prior failures that authorized retry turns in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compact-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets. -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 five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). 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. +The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) owns the current configuration shape. Provider adapters register their nested `retryPolicy`; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five 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). 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. +The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its 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. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. 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 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. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; 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. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. ### Make one layer own visible attempts @@ -92,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### 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. +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 closes the failed turn, opens the next numbered turn, 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 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. @@ -101,7 +91,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - 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. +- 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 @@ -110,7 +100,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - **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. +- **Retry forever while the caller remains active** — the [per-provider policy](../feature/2026-07-24-provider-retry-policies.md) supersedes this rejection for explicit `always` entries while retaining bounded normal mode as the default. - **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. @@ -120,11 +110,11 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - 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 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. +- `agent/request-error` carries current failure facts, immutable prior-retried failure facts, and the serving registration's immutable retry policy; a success clears the history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. +- Each provider adapter validates its nested retry policy at Loader startup, and `ctx.llm` captures it with the route; normal mode delegates ineligible paths and makes at most `maxRetries + 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. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, 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 drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - 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. @@ -132,12 +122,12 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Consequences -- 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. +- Every retry attempt is visible as a closed failed turn plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success. - 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 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. +- Multiple normal recovery plugins add their finite budgets. Always mode delegates first and then supplies an unbounded fallback; overlapping classifiers remain registration-order policy and must be documented and tested by the plugins that introduce them. ## Related diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 09ebce376a..00dcbad3d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -4,11 +4,13 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。 + ## 问题 -`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。 +`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 -该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 此前的边界还留有三个较窄的缺口。 @@ -16,7 +18,7 @@ Status: implemented - 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。 - 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。 -本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 +默认策略的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 ## 决策 @@ -50,31 +52,19 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误 `@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 -`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 +`agent/request-error` seam 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及实际服务注册所对应的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compact-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 -该插件在加载时解析并验证以下部署配置: - -```ts ignore-check -interface Config { - maxTransientRetries?: number - initialDelayMs?: number - maxDelayMs?: number - jitterRatio?: number - retryableCodes?: string[] -} -``` - -默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。 +当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 -插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall(瀑布式事件)工作与退避。effect 清理会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件释放后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 -休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 -对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。 +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 -agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。 +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 ### 由单一层负责可见的尝试 @@ -92,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -101,7 +91,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。 - 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。 - 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。 -- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。 +- 熔断器、共享提供方健康状态或跨 agent 重试预算。 - 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。 ## 考虑过的替代方案 @@ -110,7 +100,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 - **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 - **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 -- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。 +- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 - **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 - **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 @@ -120,11 +110,11 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 - DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 - Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 -- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 -- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 +- `agent/request-error` 携带当前失败事实、不可变的先前已重试失败事实,以及实际服务注册所对应的不可变重试策略;成功会清除历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 +- 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。 - 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 -- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 - 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 @@ -132,12 +122,12 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 后果 -- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 +- 每次重试尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 - 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 - 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 - 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 - 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。 -- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 +- 多个 normal 恢复插件会叠加各自的有限预算。always mode 会先委托,再提供无界回退;重叠的分类器仍会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 ## 相关资料 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 51d0eb9a78..690aa1f9b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.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-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 284cd45a66..556d5dd86d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -18,7 +18,7 @@ Three deliberate choices: 1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9486f8c35c..9d67797f86 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 6d14977e92..471cf9f92d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 +2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de +2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de 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 11d4b8cd48..63463a76a6 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 @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 334914c689..c3d3cdf1c6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index b25f335819..1904a25158 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -1,6 +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-10-after-call-compaction-pressure-and-overflow-recovery.md: b934f7fd7087006be4f7eb3659e44e78b8ede367 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 3b5b60a95bef0695a446cdd3d45d299550f449f6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 51d488db28c57426c75c9ed1cfc90892261c0224 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ae33cf5c2e944e584cd3d3c6ff76d93619adf7dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index b934f7fd70..51d488db28 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -22,9 +22,9 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after ### Request recovery is limited to the final model boundary -`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures. +`RequestError` and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. -The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. +The failed step closes before recovery runs. A handling listener repairs durable state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The loop then closes the failed turn and opens one retry turn from the durable log without an intervening idle notification. Retry policy and attempt counts remain plugin-owned; compact-basic clears its per-agent overflow count when the chain reaches terminal `agent/settled`. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns the return boundary. If cancellation lands after assistant tool calls are durable but before all calls dispatch, the loop records a synthetic `tool/call` and aborted `tool/result` pair for every undispatched call before following the normal abort path. The surface therefore never retains orphaned durable tool calls merely because cancellation won the race. @@ -34,7 +34,7 @@ If cancellation lands after assistant tool calls are durable but before all call For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets 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. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic requires no capacity metadata and 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`. +For canonical overflow, compact-basic requires no capacity metadata and 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 `{ kind: '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 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. @@ -42,7 +42,7 @@ The default summarizer resolves explicit configuration, then the latest logged r ## Testing -Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. +Unit tests cover final-adapter failure provenance and identity, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 3b5b60a95b..ae33cf5c2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -22,9 +22,9 @@ Status: implemented ### 请求恢复只覆盖最终模型边界 -`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 +`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。 -恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -34,7 +34,7 @@ Status: implemented 对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ kind: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。 @@ -42,7 +42,7 @@ Status: implemented ## 测试 -单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 +单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 ## 考虑过的替代方案 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 3a6b18cecf..c34acd2db1 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: 98205d18d07752e0cdba86d7cba80368d45fd816 -2026-07-14-provider-routed-llm-adapters.zh.md: c35225a86baf4c2d09732b5940abbc8046d365fb +2026-07-14-provider-routed-llm-adapters.md: 1bd9197667f6e49c5025c98b4a77500f78595c2b +2026-07-14-provider-routed-llm-adapters.zh.md: 4d57f2cb33ac296500a4a19771ea493621ff93f6 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 98205d18d0..1bd9197667 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, 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. +`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, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step seam. 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. 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 c35225a86b..4d57f2cb33 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、推理级别和预算、缓存保留设置、传输方式、SDK 超时和 Harness 流空闲超时。配置中有意不提供重试字段:适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;有界的 agent 层恢复由 `dsh-llm-retry` 负责。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 +`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤 seam 上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 158a78acd8..11c3d9b5a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-explicit-turn-cancellation.md: 7ac743221084e663294954bfd048ba7ef1114f60 -2026-07-16-explicit-turn-cancellation.zh.md: 3dca6339787ebef749c0d6a15609376ede994a97 +2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 +2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 7ac7432210..15085a1da2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority, and `agentInterruptReasonOf(signal)` reads only its explicit argument. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 3dca633978..e945b0fea5 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 14a271f3c7..17d864cb57 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -1,6 +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-canonical-tool-output-contract.md: 6b5cd089fcf206e659c7b67b8a996bfe81d0c333 -2026-07-20-canonical-tool-output-contract.zh.md: 61b25b14ca6f048b73a51788f112165745ae7106 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +2026-07-20-canonical-tool-output-contract.md: b2de9480d2659153dfb8a76ee07438d12e5b07c3 +2026-07-20-canonical-tool-output-contract.zh.md: 1ae2654fb1e1d6913bc91c4aeb380dc533a6b258 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 6b5cd089fc..b2de9480d2 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -56,7 +56,7 @@ The first-party tools preserve their existing Native text while returning domain | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed dynamic-mount handles | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | Inspection text or typed temporary-Plugin handles | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index 61b25b14ca..1ae2654fb1 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -56,7 +56,7 @@ type ToolExecutionResult = | `todo_write` | `{ todos, counts }` | | `ask_user_question` | `{ answers: [{ id, selected, custom? }] }` | | `exit_plan_mode` | `{ approved: true }` | -| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的动态挂载句柄 | +| `cordis_inspect` / `cordis_mount` / `cordis_unmount` | 检查文本或类型化的临时 Plugin 句柄 | | `structured_output` | `{ recorded: true }` | | `run_code` | `{ logs: string[], result?: JsonValue }` | diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 0c5577e3bb..418637f08f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -1,6 +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-22-unified-send-and-coalesced-user-messages.md: 12128d9e57601d0b85d20d1cb4240bb08eadc3cb -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 177d90f7116f7451b8e3c4ccf7d1577ff12ae701 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +2026-07-22-unified-send-and-coalesced-user-messages.md: 6936fbfa04c0fdaf1a8786c0465c193e9c285243 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 3af14359fa01e92f63ae3b3e51dced9a97f6419f diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 12128d9e57..6936fbfa04 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -1,4 +1,4 @@ -# Agent Note: Unify agent delivery and coalesce injected context into user/message +# Agent Note: Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message Status: implemented @@ -8,39 +8,45 @@ English | [中文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md) The agent's public driving surface had grown three near-parallel verbs — `send`, `steer`, `inject` — each with its own options type, its own live event story, and its own durable event. `send` and `steer` both queued a frozen inbox record and emitted `agent/queued`; `inject` bypassed the inbox and wrote a separate `context/message` durable event. The three verbs actually vary along only two independent axes: which queue an item joins (a whole new turn versus the active turn) and whether the item makes the model run. Encoding that 2×2 as three hand-written methods hid the symmetry, made "queue a turn without waking the driver" unreachable, and left `cancel()` with no way to abort a turn while preserving queued work. -Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried `source`/`meta` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). +Separately, `context/message` and `user/message` had converged: the surface projected both as verbatim user-role content, and the only real difference was that injected context carried a non-user `source` and was "not a prompt." Two event types for one projection meant every consumer branched on event type to answer "is this a human prompt?", and the goal system used the type split as a side channel (round-zero state changes were `context/message`, admitted rounds were `user/message`). ## Decision -**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering. +**One primitive, three preset aliases.** The `Agent` interface's `send(input, { target, wakeup })` covers the (`target` × `wakeup`) matrix. Its `UserMessageData` input owns the inseparable model-facing `content` and producer `source`; the complete `SendOptions` owns only routing policy. `followup` (`next-turn`/wakeup), `steer` (`next-step`/wakeup), and `inject` (`next-step`/no-wakeup) each accept that one input and fix the policy. `wakeup` means "make the model run": wake a parked driver for a `next-turn` item, or force a continuation for a running `next-step` item. `next-turn`/no-wakeup (queue without waking) is representable with no alias and no current caller. -**inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`. +**inject keeps its mechanism.** The `next-step`/no-wakeup path is exactly the old `inject`: durable model-facing context appended at the current log position, deferred while prompt admission or a turn owns the next safe boundary, and appended directly outside that window. It bypasses the FIFOs entirely, while its required `UserMessageData.source` preserves the caller's explicit provenance. -**context/message is gone.** Injected context is now a `user/message` whose `source` is a non-`user` kind (plugin or goal). `PromptMessageData` gained the optional `meta` that `context/message` carried. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. This keeps goal-authority's human-authority check exactly as strict as before — an injected message defaults to a plugin source and can never satisfy `source.kind === 'user'`. +**context/message is gone.** Injected context is now a `user/message`; context producers supply the appropriate non-user `source` explicitly, and typed source variants carry any domain-specific durable provenance. The surface, derivation, and `SurfaceEventType` drop `context/message`; consumers that need "is this a human prompt?" read `source.kind === 'user'` instead of the event type. -**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` carrying `goal/change` metadata; a positive round is an admitted continuation prompt. `decodeGoalEvent` now takes a `user/message` and still fails loud on goal metadata under a non-goal source or a goal source lacking metadata. +**Goal replay disambiguates by round, not type.** A goal state change is a round-zero goal-sourced `user/message` whose source carries the complete change; a positive round is an admitted continuation prompt. `decodeGoalEvent` takes a `user/message` and fails loud when goal-state content and its typed source disagree. -**Delivery returns an id.** Each delivery method returns an opaque branded `AgentMessageId` for the accepted input. FIFO methods carry it through their inbox lifecycle events; injection bypasses those events. +**`send` returns an id.** `send` (and the aliases) return an opaque branded `AgentMessageId` for the accepted message; `send`'s previous return was `void`. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) each carry an `AgentMessage` — the accepted message including its returned `id`, steering/wakeup facts, source, and contexts — so a caller can correlate a queued item with its lifecycle. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including the loop-authored continuation-reason steer (`agent/turn-continuation` returning `{ action: 'continue', reason }`), so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) type their `AgentMessage` payload with only the accepted message's returned `id`, content, and source. Enqueue separately carries the resolved `queued | steering` placement captured by the producer at acceptance time, so observers and reconnect mirrors never reconstruct routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. -**cancel gains keepInbox.** `cancel(cause?, { keepInbox? })`; when true it aborts the active turn but preserves queued and steering items (no discard event, and un-started work is not dropped). +**Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. + +**One accepted message keeps one representation.** Durable user-role input and additional model-facing context both use `UserMessageData { content, source }` directly; public `AgentMessage` extends it with the correlation `id`, and the loop-private `PendingMessage` extends that with `wakeup`. The loop clones and freezes `UserMessageData` before publication, queueing, or immediate append, so later caller or observer mutation cannot change the accepted value. A queued message that becomes steering enters the outbox as the same `PendingMessage` object, while injected and tool-produced context enters as plain `UserMessageData`. The outbox therefore stores their union directly instead of wrapping steering beside a duplicate copy of its content and source. Provider-native assistant messages remain adapter-owned output types and do not participate in this input hierarchy. + +**Idle wakeup follows acceptance.** Before publishing enqueue, a waking queued send installs quiescence ownership and schedules driver admission for a microtask that runs after the id returns. Every send in one synchronous caller stack therefore resolves placement against the same pre-admission state, while reentrant cancellation or teardown cannot retire before the scheduled admission settles. Two idle `steer()` calls remain two FIFO turns instead of the first opening an admission window that captures the second. + +**cancel gains keepInbox.** `cancel(cause, { keepInbox? })`; callers choose the cause explicitly, and `keepInbox: true` aborts the active turn while preserving queued and steering items (no discard event, and un-started work is not dropped). ## Alternatives considered -- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Injected context defaults to a plugin source instead. -- **A typed discriminant field on `PromptMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. -- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the accepted routing facts, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **A dedicated `MessageSource` kind `context`** for injected content. Rejected because `plugin` already means "not a human," so a fourth kind would add a parallel axis the authority checks would have to learn. Plugin-produced injected context supplies its plugin source explicitly. +- **A typed discriminant field on `UserMessageData`** (e.g. `origin: 'prompt' | 'context'`) to replace the event-type split. Rejected in favor of `source`, which every consumer already carries and which the goal system already keyed on; a second discriminant would duplicate that fact. +- **Keeping `agent/queued` alongside the inbox events.** Rejected as a mirror: `agent/inbox/enqueue` is the same enqueue-time signal with the resolved placement, and the dequeue/discard events complete the FIFO lifecycle the single event could not describe. +- **Derive inbox placement from agent status or the session log.** Rejected because `running` includes admission and settlement, while reconnect baselines need the original acceptance result even when the earlier turn boundary is absent. The producer already owns the exact routing decision. ## Consequences -The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`. +The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. -Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. ## Related - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md) — the one-claimed-message-per-turn rule this builds on. - [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — the precedent for collapsing a mirrored live event. - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md) — the cancel-cause signal `keepInbox` extends. -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md) — the public helpers and fully resolved acceptance interface. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 177d90f711..3af14359fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 统一 agent 投递并把注入的上下文合并进 user/message +# Agent Note: 将 agent 投递统一到 send(target × wakeup) 并把注入的上下文合并进 user/message Status: implemented @@ -8,39 +8,45 @@ Status: implemented agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`、`steer`、`inject`——各自带有独立的选项类型、独立的实时事件叙事,以及独立的持久事件。`send` 和 `steer` 都会把一条冻结的 inbox 记录入队并发出 `agent/queued`;`inject` 则绕过 inbox,写入一条独立的 `context/message` 持久事件。这三个动词实际上只沿两条独立的轴变化:一个队列项加入哪个队列(一个全新的轮次,还是当前活跃的轮次),以及这个队列项是否让模型运行。把这个 2×2 编码成三个手写方法,掩盖了其中的对称性,让“排入一个轮次但不唤醒驱动器”无法表达,也让 `cancel()` 无从在保留排队工作的前提下中止一个轮次。 -另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带 `source`/`meta` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 +另外,`context/message` 与 `user/message` 已经趋同:对外接口把二者都投影为逐字的 user 角色内容,唯一真正的区别是注入的上下文携带非 user `source` 且“不是提示词”。一个投影对应两种事件类型,意味着每个消费方都要根据事件类型分支来回答“这是不是一条人类提示词?”,而 goal 系统把这种类型区分当作侧信道使用(第 0 轮的状态变更是 `context/message`,已准入的轮次是 `user/message`)。 ## 决策 -**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。 +**一个原语,三个预设别名。** `Agent` 接口的 `send(input, { target, wakeup })` 覆盖 (`target` × `wakeup`) 矩阵。其 `UserMessageData` 输入持有不可分割的模型可见 `content` 与生产方 `source`;完整的 `SendOptions` 只持有路由策略。`followup`(`next-turn`/wakeup)、`steer`(`next-step`/wakeup)和 `inject`(`next-step`/no-wakeup)都接收这一项输入并固定策略。`wakeup` 意为“让模型运行”:为一个 `next-turn` 队列项唤醒处于停泊状态的驱动器,或为一个运行中的 `next-step` 队列项强制继续执行。`next-turn`/no-wakeup(入队但不唤醒)可以表达,只是没有别名,也没有当前调用方。 -**inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。 +**inject 保留其机制。** `next-step`/no-wakeup 路径正是旧的 `inject`:持久的面向模型上下文会追加到当前日志位置;提示词准入或一个轮次占有下一个安全边界时,它会延迟处理,而在该窗口之外则直接追加。它完全绕过 FIFO 队列,而必填的 `UserMessageData.source` 会保留调用方显式提供的来源信息。 -**context/message 已移除。** 注入的上下文现在是一条 `user/message`,其 `source` 为非 `user` 类别(plugin 或 goal)。`PromptMessageData` 新增了 `context/message` 原本携带的可选 `meta`。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。这让 goal-authority 的人类授权检查与此前一样严格——注入的消息默认使用 plugin 来源,永远无法满足 `source.kind === 'user'`。 +**context/message 已移除。** 注入的上下文现在是一条 `user/message`;上下文生产方显式提供合适的非 `user` 类别 `source`,类型化 source 变体携带所有特定于领域的持久来源信息。对外接口、派生逻辑和 `SurfaceEventType` 都不再包含 `context/message`;需要判断“这是不是一条人类提示词?”的消费方改为读取 `source.kind === 'user'`,而不是事件类型。 -**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,携带 `goal/change` 元数据;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 现在接收一条 `user/message`,并仍会在非 goal 来源携带 goal 元数据、或 goal 来源缺少元数据时立即报错。 +**goal 回放靠轮次而非类型来区分。** 一次 goal 状态变更是一条第 0 轮、来源为 goal 的 `user/message`,其 source 携带完整变更;正数轮次则是一条已准入的继续执行提示词。`decodeGoalEvent` 接收一条 `user/message`,并在 goal 状态内容与其类型化 source 不一致时立即报错。 -**投递返回一个 id。** 每种投递方法都为被接受的输入返回一个不透明的 branded `AgentMessageId`。FIFO 方法通过其 inbox 生命周期事件携带这个 id;注入绕过这些事件。 +**`send` 返回一个 id。** `send`(以及其别名)为被接受的消息返回一个不透明的 branded `AgentMessageId`;`send` 此前的返回值是 `void`。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都携带一条 `AgentMessage`——即被接受的消息,包含其返回的 `id`、steering/wakeup 事实、来源和上下文——因此调用方可以把一个排队项与其生命周期关联起来。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括由 loop 生成的携带继续原因的 steer(`agent/turn-continuation` 返回 `{ action: 'continue', reason }`),因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都将各自的 `AgentMessage` 载荷类型限定为仅包含被接受消息所返回的 `id`、内容和来源。enqueue 还会单独携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像永远不必根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 -**cancel 新增 keepInbox。** `cancel(cause?, { keepInbox? })`;当其为 true 时,它中止活跃轮次,但保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 +**准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 + +**一条已接受消息只保留一种表示。** 持久的用户角色输入和附加的模型可见上下文都直接使用 `UserMessageData { content, source }`;公开的 `AgentMessage` 在此基础上增加用于关联的 `id`,循环私有的 `PendingMessage` 再增加 `wakeup`。循环会在发布、入队或立即追加前克隆并冻结 `UserMessageData`,因此调用方或观察方后续的修改无法改变已接受的值。一条成为 steering 的排队消息会以同一个 `PendingMessage` 对象进入 outbox,而注入和工具产生的上下文则以普通 `UserMessageData` 进入。因此,outbox 直接存储这两种类型的联合,而不再把 steering 与一份重复的内容和来源副本包装在一起。提供方原生的助手消息仍是适配器拥有的输出类型,不参与这套输入层级。 + +**空闲唤醒在接受之后发生。** 在发布 enqueue 前,一次会唤醒驱动器的排队发送会先取得完全停稳所有权,并把驱动器准入调度到一个会在该次发送返回 id 后运行的微任务中。因此,同一同步调用栈中的每次发送都会基于同一份准入前状态解析放置方式,而可重入的取消或拆除在已调度的准入结算前无法完成退役。空闲时的两次 `steer()` 调用会保留为两个 FIFO 轮次,而不会因第一次调用打开准入窗口而把第二次吸纳进去。 + +**cancel 新增 keepInbox。** `cancel(cause, { keepInbox? })`;调用方显式选择 cause,且 `keepInbox: true` 会中止活跃轮次,同时保留排队项和 steering 项(不发出 discard 事件,尚未启动的工作也不会被丢弃)。 ## 考虑过的替代方案 -- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。注入的上下文改为默认使用 plugin 来源。 -- **在 `PromptMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 -- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是多带了已接受的路由事实,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **为注入内容设立专门的 `MessageSource` 类别 `context`。** 不予采纳,因为 `plugin` 已经表示“不是人类”,因此第四种类别会增加一条平行的轴,让授权检查不得不去学习它。由插件产生的注入上下文会显式提供其 plugin 来源。 +- **在 `UserMessageData` 上设一个类型化的判别字段**(例如 `origin: 'prompt' | 'context'`)来取代事件类型的区分。不予采纳,转而采用 `source`——每个消费方都已经携带它,goal 系统也已经以它为键;第二个判别字段会重复这一事实。 +- **在 inbox 事件之外保留 `agent/queued`。** 作为镜像而被否决:`agent/inbox/enqueue` 是同一个入队时刻的信号,只是带有已解析的放置方式,而 dequeue/discard 事件补全了单个事件无法描述的 FIFO 生命周期。 +- **根据 agent 状态或会话日志推导 inbox 放置方式。** 不予采纳,因为 `running` 同时涵盖准入与结算,而重连基线即使缺少此前的轮次边界,也需要最初的接受结果。生产方已经拥有精确的路由决策。 ## 后果 -具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。 +投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 -在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 ## 相关 - [one-send-one-turn](../simplification/2026-07-17-one-send-one-turn.md)——本决策所依托的“每轮次只认领一条消息”规则。 - [remove-agent-steering-mirror](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md)——折叠镜像实时事件的先例。 - [explicit-turn-cancellation](2026-07-16-explicit-turn-cancellation.md)——`keepInbox` 所扩展的取消原因信号。 -- [intent-named-agent-delivery](2026-07-24-intent-named-agent-delivery.md)——公开辅助方法以及接受完全解析输入的接口。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md deleted file mode 100644 index 32b0502350..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md +++ /dev/null @@ -1,52 +0,0 @@ -# Agent Note: Name public agent delivery by intent - -Status: implemented - -English | [中文](2026-07-24-intent-named-agent-delivery.zh.md) - -## Problem - -A configurable `send(content, { target?, wakeup?, ... })` makes every caller learn the loop's routing matrix, its defaults, and the interaction between active-turn targeting and model activation. Optional routing fields also let advanced-looking calls silently become ordinary sends. Most callers have one semantic intent, while some adapters already possess exact routing facts and should not have to reverse-map them into a helper name. - -Sharing helper implementations through an abstract `Agent` class also makes the public seam nominal in practice. Object-literal adapters and tests must inherit prototype methods even though the package promises a swappable structural handle. The shared base exists only to forward fixed arguments, while the concrete loop remains the sole production adapter. - -## Decision - -`Agent` is a structural interface with four intent-named delivery helpers: - -- `followup()` queues an ordinary turn and wakes the driver. -- `queue()` queues an ordinary turn without waking an idle driver. -- `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn. -- `inject()` appends model-facing context without running the model. - -`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts. - -`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery. - -The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface. - -## Alternatives considered - -**Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state. - -**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection. - -**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded. - -**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation. - -**Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content. - -## Verification - -Focused agent-loop coverage exercises direct fully resolved acceptance, waking sends, quiet queues, active and idle steering, injection, source/context snapshots, cancellation, and inbox lifecycle correlation through the public methods. Type-level coverage uses structural `Agent` fakes, requires every `ResolvedAgentInput` field, requires empty contexts on its injection variant, and keeps routing fields out of `SendOptions`. The keyless Cordis inspection snapshot pins the structural interface without an abstract-class implementation. - -## Consequences - -Ordinary callers choose one verb instead of encoding two routing axes; advanced callers may submit the exact discriminated route. The concrete loop retains one acceptance path and one ownership boundary, while the structural interface preserves simple adapters and fakes. Adding a common delivery intent still requires an explicit public helper and mapping rather than another optional matrix combination. - -The advanced method adds interface surface and requires structural fakes to implement it. In return, resolved routing has one typed representation, while helper defaults and mappings stay beside the only implementation that owns them. - -## Related - -- [unified delivery and coalesced user messages](2026-07-22-unified-send-and-coalesced-user-messages.md) owns the shared acceptance mechanism, inbox lifecycle, and durable event convergence this decision narrows at the public seam. diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md b/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md deleted file mode 100644 index ce8860b397..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md +++ /dev/null @@ -1,52 +0,0 @@ -# Agent Note: 按意图命名公开的 agent 投递 - -Status: implemented - -[English](2026-07-24-intent-named-agent-delivery.md) | 中文 - -## 问题 - -可配置的 `send(content, { target?, wakeup?, ... })` 会迫使每个调用方理解循环的路由矩阵、默认值,以及活跃轮次目标与模型激活之间的相互作用。可选路由字段还会让看似高级的调用悄然变成普通投递。大多数调用方只有一种语义意图,而有些适配器已经持有确切的路由信息,不应再被迫将这些信息反向映射为某个辅助方法名称。 - -通过抽象 `Agent` 类共享辅助方法的实现,实际上也会让公开 seam 具有名义类型约束。对象字面量适配器和测试必须继承原型方法,尽管该包承诺提供一个可替换的结构化句柄。共享基类只负责转发固定参数,而具体循环仍是唯一的生产适配器。 - -## 决策 - -`Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法: - -- `followup()` 将一个普通轮次入队并唤醒驱动器。 -- `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。 -- `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。 -- `inject()` 追加面向模型的上下文,但不运行模型。 - -`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词。 - -`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。 - -结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。 - -## 考虑过的替代方案 - -**让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。 - -**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。 - -**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃。 - -**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作。 - -**先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。 - -## 验证 - -聚焦的 agent-loop 覆盖率测试通过公开方法覆盖直接接受完全解析的输入、唤醒式投递、静默排队、活跃与空闲状态下的 steering(中途引导)、注入、来源与上下文快照、取消,以及 inbox 生命周期关联。类型级覆盖使用结构化 `Agent` 测试替身,要求提供 `ResolvedAgentInput` 的每个字段,要求其注入变体的上下文为空,并确保 `SendOptions` 不包含路由字段。无密钥的 Cordis 检查快照固定了不采用抽象类实现的结构化接口。 - -## 后果 - -普通调用方选择一个动词即可,无需编码两条路由轴;高级调用方则可提交经过判别的精确路由。具体循环保留一条接受路径和一个归属边界,而结构化接口保留了对简单适配器和测试替身的支持。新增一种常见投递意图时,仍需要显式提供公开辅助方法及其映射,而不是再增加一种可选的矩阵组合。 - -这个高级方法会扩大接口范围,并要求结构化测试替身实现它。作为回报,解析后的路由只有一种类型化表示,而辅助方法的默认值和映射仍留在拥有它们的唯一实现旁边。 - -## 相关 - -- [统一投递并合并 user 消息](2026-07-22-unified-send-and-coalesced-user-messages.md)负责定义共享的接受机制、inbox 生命周期和持久事件趋同;本决策只收窄它们的公开 seam。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml new file mode 100644 index 0000000000..bd19eb7b18 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.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 .agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +2026-07-24-separate-context-injection-from-turn-execution.md: b74cd6bdc48e795e57d780ab31a907ffe94dd518 +2026-07-24-separate-context-injection-from-turn-execution.zh.md: f2421d2fc7b8c1329dd1349a6fb088407ac5fc75 diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md new file mode 100644 index 0000000000..b74cd6bdc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.md @@ -0,0 +1,74 @@ +# Agent Note: Separate context injection from turn execution + +Status: implemented + +English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) + +## Problem + +The agent API represented supplementary model-facing input in three overlapping ways: callers attached `HookContext[]` through `SendOptions.contexts`, interception and tool hooks returned `additionalContexts`, and plugins called `agent.inject()`. These paths eventually wrote context into the same model history, but carried different placement, metadata, admission, queue, and turn-lifecycle rules. + +Atomic attachment to an inbox message forced the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combined context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers could recover what the user actually wrote. The result made outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. + +Idle `inject()` exposed a second mismatch. Injection did not request model execution, yet the implementation opened and closed a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes meant “run the agent loop” and sometimes meant “persist context without running it.” + +`HookContext` also named its producer rather than its role. The value could come from a native plugin, a hook bridge, prompt admission, or tool post-processing; its stable meaning was additional model-facing context with provenance. + +## Decision + +`inject()` is the only caller-facing operation for supplementary model-facing input, and a turn means one execution of the model loop. + +`SendOptions` contains only `target` and `wakeup`. A caller that owns context delivers `UserMessageData` through `inject()` and submits the direct message independently with `send()` or `steer()`. + +Prompt and tool extension points still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt and its returned additional contexts enter the new turn as separate messages; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the outbox after the corresponding tool results. + +Every additional context is an independent `user/message` whose `source` records provenance. There is no `context/message`, prompt-prefix placement, stable request delimiter, or prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`. + +## Injection lifecycle + +During prompt admission or an open turn, `inject()` stages context in the loop outbox. The private next-step acceptance window opens before `agent/prompt-submit` and closes before `turn/end`, so steering and context accepted for one boundary reach the same following request while a `turn/end` listener's late steering becomes a queued prompt. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. + +Outside that window, `inject()` appends its `user/message` immediately. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model; persistence observes the append through `session/event`. + +If prompt admission blocks or fails, a caller-staged context-only batch appends immediately without a turn. Steering and context staged beside it remain in the outbox for a later admitted prompt; cancellation or disposal may discard them. Hook-produced `additionalContexts` never materialize because they belong to the rejected admission decision. + +The session invariant permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. + +## Extension and caller semantics + +`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. + +Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. Outside a next-step acceptance window, a caller that invokes `inject(context)` and then `send(prompt)` commits context independently; callers requiring all-or-nothing behavior use a domain-specific admission wrapper. + +Cross-session references use that domain composition: TUI prepares the snapshot, then either adds it to the prompt's admission decision outside an acceptance window or injects it beside steering during one. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. + +This decision preserves the caller-owned framing decision from [unwrapped injected content](../simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. + +## Alternatives considered + +**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. + +**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. + +**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. + +**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. + +**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. + +## Verification + +- `SendOptions` and steering inbox records contain no attached contexts; `agent/inbox/enqueue` reports only the message plus its resolved queued-or-steering placement. +- `UserMessageData` is the shared shape across prompt interception, tool execution, hook bridges, guards, and context producers. +- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. +- Idle `inject()` appends one sourced `user/message` without a turn or model call. +- Admission-time and active-turn injection drain at safe boundaries after complete tool-result batches and before the request that consumes them. +- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; caller context alone falls back to an idle append, while a steering boundary remains available to retry. +- Unit, persistence/resume, invariant, host/client queue, and TUI coverage pin event order, admission ownership, and reconnect classification. + +## Consequences + +- One surface event is valid outside turns, so persistence scanning, crash repair, forking, compaction, and session queries distinguish execution enclosure from session history. +- Consecutive user-role messages replace one baked prompt message; provider adapters preserve that ordering. +- Outside an acceptance window, `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller supplies domain-specific admission ownership. +- The public delivery contract and inbox records remain small: no context attachment, context-placement metadata, prompt envelope, or duplicate durable event type. diff --git a/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md new file mode 100644 index 0000000000..f2421d2fc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 将上下文注入与轮次执行分离 + +Status: implemented + +[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 + +## 问题 + +agent API 曾用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 + +将上下文原子附加到收件箱消息后,agent loop(智能体循环)曾被迫让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又曾把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方不得不依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都曾负责处理本应由生产方负责的区分。 + +空闲状态下的 `inject()` 还暴露了另一处语义错位。注入当时并不请求模型执行,但实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,当时的轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 + +`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义是带来源信息的额外模型上下文。 + +## 决策 + +`inject()` 是调用方交付补充模型输入的唯一操作,而轮次表示一次模型循环执行。 + +`SendOptions` 只包含 `target` 和 `wakeup`。拥有上下文的调用方通过 `inject()` 交付 `UserMessageData`,再独立使用 `send()` 或 `steer()` 提交直接消息。 + +提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。获准的提示词及其返回的额外上下文会作为独立消息进入新轮次;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入 outbox。 + +每项额外上下文都是独立的 `user/message`,并由 `source` 记录来源。不再有 `context/message`、prompt-prefix 放置方式、稳定请求分隔符或提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文。 + +## 注入生命周期 + +提示词准入期间或轮次打开时,`inject()` 会将上下文暂存在 loop outbox 中。私有的 next-step 接受窗口在 `agent/prompt-submit` 前打开,并在 `turn/end` 前关闭,因此同一边界接受的 steering 和上下文会进入后续同一次请求,而 `turn/end` 监听器提交的晚到 steering 则成为排队提示词。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接受的上下文,只能出现在该批次所有有序结果之后。 + +在该窗口之外,`inject()` 会立即追加对应的 `user/message`。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型;持久化通过 `session/event` 观察这次追加。 + +如果提示词准入被阻止或失败,调用方暂存的仅含上下文的批次会立即追加,且不产生轮次。steering 及与其一同暂存的上下文会留在 outbox 中,供后续获准提示词使用;取消或 dispose(资源释放)可能丢弃它们。钩子产生的 `additionalContexts` 属于被拒绝的准入决策,因此永远不会落入日志。 + +会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork 和压缩会把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 + +## 扩展点与调用方语义 + +`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 + +调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。在 next-step 接受窗口之外,调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,会独立提交上下文;需要全有或全无语义的调用方应使用领域专用的准入包装层。 + +跨会话引用采用这种领域组合方式:TUI 先准备快照,然后在接受窗口之外将其加入提示词准入决策,或在窗口期间将其注入到 steering 旁。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本决策取代[跨会话引用决策](../feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 + +本决策保留[移除注入内容封套](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 + +## 曾考虑的替代方案 + +**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 + +**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 + +**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 + +**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 + +**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 + +## 验证 + +- `SendOptions` 与 steering 收件箱记录不包含附加上下文;`agent/inbox/enqueue` 只报告消息及其已解析的 queued 或 steering 放置方式。 +- `UserMessageData` 是提示词拦截、工具执行、hook bridge、guard 和上下文生产方共享的形状。 +- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 +- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加一条带来源的 `user/message`。 +- 准入期间和活跃轮次中的注入会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 +- 被阻止的提示词准入不会打开轮次,也不会追加提示词或钩子产生的额外上下文;仅有调用方上下文时会回退为空闲追加,而带 steering 的边界仍可重试。 +- 单元测试、持久化与 resume 测试、不变量测试、宿主/客户端队列测试和 TUI 覆盖会固定事件顺序、准入归属和重连分类。 + +## 后果 + +- 一个表层事件可以合法位于轮次之外,因此持久化扫描、崩溃恢复、fork、压缩和会话查询需要区分执行封闭与会话历史。 +- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器会保留这一顺序。 +- 在接受窗口之外,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文,除非调用方提供领域专用的准入归属。 +- 公共投递契约和收件箱记录保持精简:没有上下文附件、上下文放置元数据、提示词封套或重复的持久事件类型。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 529e319ee0..f3a525fe70 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -1,6 +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-25-web-client-session-scope-and-provide-channel.md: 063494b56461593015d6de4c2b55a2d1d6a3c676 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: cd5d29dfbcd9356a9ea15852d5d27a3660084abf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 063494b564..09afe6d9e8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -101,7 +101,7 @@ Slot scope is the closed set `root | session-maybe | session`: ### The read-only queue mirror -- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match); queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. +- The MuxFrame `session/queued`: the Session holds a read-only inbox mirror (previews truncated; steering retired by source match). The host stamps the agent-loop's acceptance-time steering classification on live and replayed frames, so a reconnect baseline does not depend on replaying an earlier `turn/start`. Queue frames never enter history — pure stream state, cleared on reconnect and refilled from the new baseline; the never-instantiated window is buffered and replayed through the manager pendingBuffers. - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. ### Host wire smalls diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index cd5d29dfbc..4d45d74c2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -101,7 +101,7 @@ slot scope 是闭集 `root | session-maybe | session`: ### 队列只读镜像 -- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休);queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 +- MuxFrame `session/queued`:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 `turn/start`。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 ### host wire 小件 diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml similarity index 58% rename from .agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 233cc3901c..82047e9d87 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.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-24-separate-context-injection-from-turn-execution.md: 652c3d410ab625d91a828f854bce302adcb0c9e0 -2026-07-24-separate-context-injection-from-turn-execution.zh.md: 1064e7a869ab9ea46c0145eb010119894a03aacf +2026-07-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c +2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md new file mode 100644 index 0000000000..b31f69f125 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it + +Status: implemented + +English | [中文](2026-07-26-subprocess-consumer-migration.zh.md) + +## Problem + +The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable. + +## Decision + +The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does: + +- **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). +- **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. +- **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. + +Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). + +Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` (the plugins inject `'subprocess'`); the acp/lsp test fixtures gained the row. + +## Alternatives considered + +**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. + +**A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases. + +**Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put. + +**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead. + +## Consequences + +Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. + +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md new file mode 100644 index 0000000000..47e7519a72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 + +Status: implemented + +[English](2026-07-26-subprocess-consumer-migration.md) | 中文 + +## 问题 + +[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 + +## 决策 + +这道 seam 的词汇如今已是 Node 形状,凡能接入该服务的 spawn 调用点均已迁入: + +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 +- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 + +各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 + +挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 + +## 曾考虑的替代方案 + +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 + +**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 + +**把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 + +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 + +## 后果 + +换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 + +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml similarity index 63% rename from .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index ec8c9d105f..6c6b1d6b44 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb -2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29 +2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f +2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md new file mode 100644 index 0000000000..ad2f8522be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +English | [中文](2026-07-26-subprocess-seam.zh.md) + +## Problem + +`dsh-bash-local` bundled two capabilities that change for different reasons: *running a bash command* (command defaulting, timeout classification, model-friendly terminal environment, the stdout/stderr merge the bash tool renders) and *running and managing a child process* (detached process groups, bounded tail-keep output with spill files, the credential scrub and `DSH_*` merge order, SIGTERM→grace→SIGKILL escalation, kill-and-join disposal). The process half — `run.ts`, roughly half the package — had no seam of its own: a future non-shell runner (a direct-argv executor, a worker supervisor) would have to re-implement or reach into bash internals, and the shared `DSH_*`/`CollectedOutput` vocabulary lived in a package whose name promises shell semantics. The bundling also tied background-process lifetime to the executor's fiber: reloading the bash executor killed every live background process, unlike the sibling [task registry](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: + +- **`@deepseek-ai/dsh-subprocess` (interface)** — the abstract `SubprocessService` owning `ctx.subprocess` with one method, `spawn(spec): SubprocessHandle`, and the shared vocabulary: the fully-explicit `SubprocessSpawnSpec` (argv, cwd, per-stream stdio dispositions, grace — no defaults; deployment-varying knobs stay with the calling seam's config, per the `dsh-bash` request/spec template and the no-hidden-defaults rule), `SubprocessHandle` with non-consuming offset-based readers, `SubprocessOutcome` with deliberately no timeout/cancel classification, and the shared scrub plus `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. (The [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) later widened the stdio and termination vocabulary Node-ward.) +- **`@deepseek-ai/dsh-subprocess-local` (implementation)** — `LocalSubprocessService` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the explicit-env merge after it, group kill escalation, and disposal that kills and joins every still-running managed process. It has no config; every limit arrives on the spec. The terminal `ENV_OVERRIDES` (`TERM=dumb` etc.) did NOT move — that is bash-tool presentation policy and stays in `dsh-bash-local`, merged through the ordinary env channel. +- **`dsh-bash-local` (consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. +- **`dsh-bash` (seam)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. + +Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). + +Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral seam shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. + +## Alternatives considered + +**Leave the process plumbing inside `dsh-bash-local` (status quo).** Rejected for the same reason the [task registry split](2026-07-26-task-registry-seam.md) landed: the boundary is stable and already documented in-code (`run.ts`'s module doc said "this layer reacts to an abort signal; the executor owns deadlines and classifies causes"), and keeping it private makes every future non-shell runner either fork the mechanics or depend on a bash-named package for non-bash work. The user-visible driver for this stack was exactly this split. + +**Migrate the repo's other spawn sites (lsp-local, pty-local, subagent-subprocess, sdk package-manager, test-support launchers) onto `ctx.subprocess` in the same change.** Rejected as scope creep with real design risk at this PR's scale: those sites have materially different stream and lifecycle needs — node-pty ownership (pty), LSP framing over long-lived stdio with tree-kill fallbacks (lsp), stdin-EOF-first disposal ladders and no output buffering (subagent transports) — and forcing them under a handle shaped for bounded batch output would either bloat the seam or misfit the consumers. The seam shipped proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule. Review then asked for exactly that follow-up as a stacked PR; the [consumer-migration Agent Note](2026-07-26-subprocess-consumer-migration.md) records the Node-ward reshape and which sites moved (and which stayed, by ownership). + +**Put `run_in_background`/task semantics into the process seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The process seam sits *below* the bash executor, not beside the task registry. + +**Move `ENV_OVERRIDES` (TERM=dumb, PAGER=cat …) into the subprocess service.** Rejected: a generic subprocess service must not impose terminal presentation policy on non-terminal consumers; the ambient scrub (credential-shaped and `DSH_*` names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins. + +## Consequences + +Bought: "run and manage a process" is a swappable capability with the standard three-package shape (consumer count starts at two: `bash-local`, `bash-sandbox`); a containerized or remote process backend slots in without touching bash semantics; the shared `DSH_*`/output vocabulary has a non-shell home; and background processes survive executor reloads, matching the task registry's lifetime model. The spawn plumbing suite moved wholesale to `dsh-subprocess-local` (argv-based, plus argv-validation and service lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, service-owned lifetime) against the real service. + +Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the subprocess service leaves `ctx.bash` pending on `ctx.subprocess` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the subprocess seam is the owner and the bash seam documents the re-export. The spawn-failure note became single-delivery through the read path where the old plumbing retained it in the stderr buffer for repeated `readFrom(0)` reads — acceptable because the bash background read path was already a consuming cursor, and the note reaches the one reader that exists. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md new file mode 100644 index 0000000000..d9a0fb56b5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +[English](2026-07-26-subprocess-seam.md) | 中文 + +## 问题 + +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker supervisor)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 + +## 决策 + +新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: + +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及共享的凭据清除与 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。([消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 其后将 stdio 与终止词汇拓宽为 Node 形状。) +- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、清除之后合并显式 env 的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`dsh-bash-local`(消费方)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 +- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 + +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 + +后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 + +## 曾考虑的替代方案 + +**把进程管道留在 `dsh-bash-local` 里(维持现状)。**否决的理由与[任务注册表拆分](2026-07-26-task-registry-seam.md)得以落地的理由相同:这条边界既稳定,也早已记录在代码里(`run.ts` 的模块文档曾写明「this layer reacts to an abort signal; the executor owns deadlines and classifies causes」),而若继续将它保持私有,未来每个非 shell 运行器就只能要么 fork 这套机制,要么为非 bash 工作去依赖一个以 bash 命名的包。这组堆叠变更对用户可见的动因正是这一拆分。 + +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在本 PR(Pull Request)的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。评审随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md) 记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。 + +**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 + +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 + +## 后果 + +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 + +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.subprocess` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml new file mode 100644 index 0000000000..bd5964f1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 +2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md new file mode 100644 index 0000000000..97b551ff50 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -0,0 +1,23 @@ +# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam + +Status: implemented + +English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) + +## Problem + +`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds. + +## Decision + +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. + +## Alternatives considered + +**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy. + +**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted. + +## Consequences + +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md new file mode 100644 index 0000000000..b6849ad393 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam + +Status: implemented + +[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文 + +## 问题 + +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 + +## 决策 + +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 + +## 曾考虑的替代方案 + +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 + +**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 + +## 后果 + +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml new file mode 100644 index 0000000000..a22f2b68fa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.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 .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md +2026-07-27-tui-chat-channel-module-split.md: 56b345b670cd8426780bfdd8c2b2f5719461554c +2026-07-27-tui-chat-channel-module-split.zh.md: d74844a762bc519d0f499696fe343567eebfe920 diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md new file mode 100644 index 0000000000..56b345b670 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui chat channel module split + +Status: implemented + +English | [中文](2026-07-27-tui-chat-channel-module-split.zh.md) + +## Problem + +`packages/ui/tui/src/index.ts` had grown past 2000 lines. Most of it was one `createTuiChat` factory: a ~1600-line closure holding roughly forty mutable variables and as many nested closures. Model selection, the ask-user-question queue, and session resume were tangled into that single scope, so a reader could not follow any one concern without holding the whole file in their head, and unrelated edits collided. A prior pass had grouped `src/` into `components/`, `session/`, `extension/`, but the entry file itself and the loose top-level input files (`autocomplete.ts`, `file-autocomplete.ts`, `skill-invocation.ts`, `xml-tool-output.ts`) were untouched. + +## Decision + +The chat channel's cohesive sub-machines are extracted from `createTuiChat` into `src/chat/`, each a factory that takes an explicit dependency bundle instead of closing over the entry scope: + +- `chat/model-command.ts` — `createModelController`: the queued `/model` command, the model+reasoning-effort selector overlay, and the selected model's context-window resolution. Owns the context-window cache that the prompt and status views read. +- `chat/questions.ts` — `createQuestionQueue`: the user-interaction provider and the one-at-a-time FIFO ask-user-question overlays. +- `chat/resume.ts` — `createResumeController`: the `/resume` selector, per-candidate summary reads, the pre-handoff preflight, the terminal handoff, and the durable resume-hint command. +- `chat/helpers.ts` — zero-state helpers (`formatCwd`, `gitBranch`, surface/tool-call derivations, session-reference cards), the `HintEditor`, and banner-reveal constants. +- `chat/channel.ts` — `ChatChannelDeps` (the collaborator surface every sub-controller shares) and `ChannelNotice` (mixed in by the controllers that report outcomes). Each `*Deps` extends these, so the shared surface has one definition. + +`src/` is reorganized so `chat/` holds every chat-channel concern: the sub-controllers above plus the former input files and the former `session/` files (`timing.ts`, `tokens.ts`) all move under `chat/`. `xml-tool-output.ts` moves under `components/`. The host/process boundary interfaces (`TuiRuntime`, `TuiResumeHost`) move to `src/runtime.ts`. After the split `src/` is `chat/`, `components/`, `extension/`, and the top-level `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`; `index.ts` drops from 2067 to ~1530 lines and now constructs and wires the three controllers. + +The convention for a controller's dependency bundle: stable value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`, and each controller's own services) are destructured once; the channel callbacks (`appendNotice`, `requestRender`, `isDisposed`, `agentStatus`) stay on `deps` so a controller always calls the channel's current implementation. `channel.ts`'s JSDoc states this rule. + +## Alternatives considered + +- **Free functions taking a shared mutable context object.** Rejected: it would re-expose the same forty-field grab-bag the split set out to remove, just under a parameter name. +- **Extracting the status/timing animation controller too.** Deferred: `runningStatus` is read directly by the prompt caret animation in `updatePromptValues`, so a controller boundary there would leak its internal state back through getters — a leaky seam for little gain. It stays inline in `index.ts`. + +## Consequences + +Each concern is now readable and testable in isolation, and the shared dependency surface is defined once instead of copied into three interfaces. The cost: `index.ts` constructs the controllers and threads the callback bundle, and the model controller is a `let` forward-reference (`updatePromptValues` closes over it, but it is built later once `appendNotice`/`overlayManager` exist), carrying one justified `prefer-const` disable and a deferred first paint. + +## Testing + +Behavior is unchanged: all existing package tests and TUI snapshots pass without re-recording, which is the contract for this refactor. diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md new file mode 100644 index 0000000000..d74844a762 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui 聊天通道模块拆分 + +Status: implemented + +[English](2026-07-27-tui-chat-channel-module-split.md) | 中文 + +## Problem + +`packages/ui/tui/src/index.ts` 已超过 2000 行,其中绝大部分是单个 `createTuiChat` 工厂:一个约 1600 行的闭包,持有约四十个可变变量以及同等数量的嵌套闭包。模型选择、ask-user-question 队列、会话恢复都缠绕在这一个作用域里,读者无法在不把整份文件装进脑子的前提下理清任何单一关注点,互不相关的改动也会彼此冲突。此前一轮已把 `src/` 归组为 `components/`、`session/`、`extension/`,但入口文件本身以及散落在顶层的输入相关文件(`autocomplete.ts`、`file-autocomplete.ts`、`skill-invocation.ts`、`xml-tool-output.ts`)未动。 + +## Decision + +聊天通道内聚的子机制从 `createTuiChat` 中抽出,迁入 `src/chat/`,每个都是接收显式依赖包的工厂,而非闭包捕获入口作用域: + +- `chat/model-command.ts` — `createModelController`:排队执行的 `/model` 命令、模型加推理力度(reasoning-effort)的选择浮层,以及所选模型上下文窗口的解析。持有供提示行与状态视图读取的上下文窗口缓存。 +- `chat/questions.ts` — `createQuestionQueue`:user-interaction provider 以及一次仅一个的 FIFO ask-user-question 浮层。 +- `chat/resume.ts` — `createResumeController`:`/resume` 选择器、逐候选摘要读取、交接前预检、终端交接,以及持久化的恢复提示命令。 +- `chat/helpers.ts` — 无状态辅助函数(`formatCwd`、`gitBranch`、surface/工具调用派生、会话引用卡片)、`HintEditor`,以及横幅揭示常量。 +- `chat/channel.ts` — `ChatChannelDeps`(每个子控制器共享的协作者面)与 `ChannelNotice`(由需要上报结果的控制器混入)。各 `*Deps` 继承它们,使共享面只有一处定义。 + +`src/` 随之重组,使 `chat/` 汇集所有聊天通道关注点:上述子控制器,加上原来的输入文件与原 `session/` 文件(`timing.ts`、`tokens.ts`)都迁到 `chat/` 之下。`xml-tool-output.ts` 迁到 `components/` 之下。宿主/进程边界接口(`TuiRuntime`、`TuiResumeHost`)迁到 `src/runtime.ts`。拆分后 `src/` 为 `chat/`、`components/`、`extension/`,以及顶层的 `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`;`index.ts` 从 2067 行降至约 1530 行,现负责构造并接线这三个控制器。 + +控制器依赖包的约定:稳定的取值型协作者(`ctx`、`resolved`、`palette`、`overlayManager`,以及各控制器自有的服务)一次性解构;通道回调(`appendNotice`、`requestRender`、`isDisposed`、`agentStatus`)保留在 `deps` 上,使控制器始终调用通道当前的实现。`channel.ts` 的 JSDoc 陈述了此规则。 + +## Alternatives considered + +- **接收共享可变上下文对象的自由函数。** 否决:那会把拆分本要消除的四十字段大杂烩,仅换个参数名重新暴露出来。 +- **同时抽出状态/计时动画控制器。** 推迟:`runningStatus` 被 `updatePromptValues` 中的提示光标动画直接读取,在此设控制器边界会让其内部状态经 getter 反向泄漏——收益甚微的漏隙缝。它继续内联在 `index.ts` 中。 + +## Consequences + +每个关注点现可独立阅读与测试,共享依赖面只定义一次,而非复制进三个接口。代价:`index.ts` 负责构造这些控制器并穿针引线地传入回调包;模型控制器是 `let` 前向引用(`updatePromptValues` 闭包捕获它,但它要待 `appendNotice`/`overlayManager` 就绪后才构造),因而带一处有正当理由的 `prefer-const` 禁用与一次延后的首帧绘制。 + +## Testing + +行为不变:现有的包测试与 TUI 快照全部无需重录即通过,这正是本次重构的契约。 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 index 4798f54960..a7a00fd548 100644 --- 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 @@ -1,6 +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: 391e35997bb1bb050dd2ca620920961d77bb1c46 -2026-07-20-error-cause-chain-diagnostics.zh.md: 90d6559a9410e8a4e5475db9560a2a177ba7a1a7 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +2026-07-20-error-cause-chain-diagnostics.md: 7de1f4f631cec90048ccc8eab7a6654560d84846 +2026-07-20-error-cause-chain-diagnostics.zh.md: 74820e80f729343a833c926adc6187b7cc9fc372 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 index 391e35997b..7de1f4f631 100644 --- 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 @@ -15,7 +15,7 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice - `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('TRANSPORT')` naming the configured `baseURL` and chaining the original rejection as `cause`. An aborted request becomes `LlmError('ABORTED')`; because the turn signal is already aborted, the loop still classifies the turn as cancellation rather than recovery. -- 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. +- 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 live `agent/error` event and `SettleReason` preserve the thrown value as `unknown`; each diagnostic consumer renders it instead of the loop wrapping it into another error. 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. 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 index 90d6559a94..74820e80f7 100644 --- 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 @@ -15,7 +15,7 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` - `dsh-llm` 导出 `errorChain(value)`:渲染抛出值及其完整 `cause` 链(`outer: inner: …`)与 AggregateError 成员(`msg [m1; m2]`),并容错循环 cause 和恶意强制转换。它只是诊断表面的渲染器;路由仍然基于 `HarnessError.code`。 - DeepSeek 适配器把拿到响应之前的传输失败包装成 `LlmError('TRANSPORT')`,写明配置的 `baseURL` 并把原始拒绝值链为 `cause`。被中止的请求变为 `LlmError('ABORTED')`;由于轮次信号已处于中止状态,循环仍将该轮次归类为取消而非恢复。 -- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。`dsh-agent-loop`、`dsh-stdio`、`dsh-tui` 里各自的 `renderThrown` 副本被删除,统一使用这一个共享渲染器。 +- 每个诊断接缝改用 `errorChain` 而非 `error.message`/`String(error)`:agent-loop 的持久化 `turn/end` 错误消息(`errorData`)、其日志警告、TUI 的 `agent/error` 通知与启动失败行、以及 `dsh-stdio` 的启动失败日志行。实时 `agent/error` 事件与 `SettleReason` 以 `unknown` 原样保留抛出值;各诊断消费者自行渲染,而不是由循环把它包装成另一个错误。`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` 里,理由相同:它是每个消费者都已导入的叶子包,共享不增加新的依赖边。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml index e6b0fa166a..663ad725d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.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-21-compaction-summary-prefix-cache-reuse.md: 490eb57a5891bf9cd0799c5d49d25d4e9838041f -2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 02412ff07e87e12c7e7de00b5c69e1282433f735 +2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8 +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: edf9de6fe5388d75612946bfb05c4383d1856102 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md index 490eb57a58..d05d25cfa7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -6,7 +6,7 @@ English | [中文](2026-07-21-compaction-summary-prefix-cache-reuse.zh.md) ## Problem -Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + `messagePrefix` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. +Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. ## Decision @@ -14,7 +14,7 @@ The summarization directive moves from the **front** of the request (a fresh `sy ### `SummarizationInput` carries the replayed prefix, not a rendered string -`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system`, `tools`, and `messagePrefix`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. +`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system` and `tools`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. ### The instruction is a trailing user message @@ -27,7 +27,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th ## Alternatives considered - **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache. -- **Send only the shadowed region without the `system`/`tools`/`messagePrefix` head** — rejected: a shorter or differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. +- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. - **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. - **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md index 02412ff07e..edf9de6fe5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + `messagePrefix` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 ## Decision @@ -14,7 +14,7 @@ Status: implemented ### `SummarizationInput` 携带回放的前缀,而非渲染后的字符串 -`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system`、`tools` 和 `messagePrefix`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 +`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system` 和 `tools`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 ### 指令是一条尾部 user 消息 @@ -27,7 +27,7 @@ Status: implemented ## Alternatives considered - **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 -- **只发送被遮蔽区域而不带 `system`/`tools`/`messagePrefix` 头部**——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 +- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 - **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.i18n.yaml new file mode 100644 index 0000000000..567f8e8c17 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.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-22-pi-ai-transport-truncation-classification.md: 119200a788c0b0521f385f4cf4e6adf05a0512f9 +2026-07-22-pi-ai-transport-truncation-classification.zh.md: 6a1bb478a86fc6ab726968b3df5752e0ad7fc9e6 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md new file mode 100644 index 0000000000..119200a788 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.md @@ -0,0 +1,35 @@ +# Agent Note: Classify pi-ai transport truncations from flattened message text + +Status: implemented + +English | [中文](2026-07-22-pi-ai-transport-truncation-classification.zh.md) + +## Problem + +A TUI run whose model connection dropped mid-stream surfaced the single notice `terminated`, and a truncated Anthropic response surfaced `Anthropic stream ended before message_stop`. Both are transport truncations — the connection died before the provider's terminal SSE event — yet `classifyPiAiError` in `dsh-llm-pi-ai` mapped neither, falling through to the catch-all `PI_AI_ERROR`. Because `PI_AI_ERROR` is not in `llm-retry`'s `DEFAULT_RETRYABLE_CODES` (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`), a recoverable drop was treated as a permanent failure and never retried. + +The detail loss is upstream and unrecoverable in the adapter: pi-ai reduces a caught error to `error.message` (`api/anthropic-messages.js`: `errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`) before pushing the terminal `error` event, discarding the original `Error` and its `cause` chain. undici carries the actionable `SocketError` on `cause` but hands the fetch wrapper a bare `terminated`; pi-ai keeps only that word. pi-ai `SimpleStreamOptions` exposes no fetch/dispatcher/client hook we could use to capture the `cause` ourselves before it is flattened. + +## Decision + +- `classifyPiAiError` recognizes two more transport wordings and maps both to `TRANSPORT`: + - a mid-stream socket drop rendered as a bare `terminated` (undici) or `Premature close` (Node stream layer); + - a stream truncated before its terminal event, which each pi-ai provider throws with its own wording (`Anthropic stream ended before message_stop`, `… before a terminal response event`, `… ended without a terminal event`, `Stream ended without finish_reason`), matched on `stream ended before/without`. +- The classifier carries an `XXX(pi-ai upstream)` note naming the flattening site and stating the intended fix: classify on `code`/`cause` if pi-ai ever forwards the original `Error` or a hook that lets us capture the `cause`. Classification stays best-effort text matching until then. +- `llm-pi-ai/README.md` gains a Known-Limitations bullet recording that pi-ai flattens the cause chain and that harness codes are therefore classified from message text. + +Classification stays on message text because that is the only signal pi-ai delivers; the `XXX` marks it as a workaround, not the desired end state. + +## Alternatives considered + +**Capture the `cause` via a pi-ai fetch/dispatcher/client hook.** Rejected: pi-ai 0.81.1 exposes none. `StreamOptions` offers only `onPayload`/`onResponse`; `onResponse` fires before the body stream is consumed, so it cannot observe a mid-stream drop. The Anthropic path accepts a `client` object, but constructing and injecting a provider SDK client per request to intercept transport errors reaches around the adapter seam for one diagnostic string. + +**Leave both as `PI_AI_ERROR` and widen `llm-retry`'s retryable set.** Rejected: `PI_AI_ERROR` is the catch-all for genuinely unclassified failures, including non-retryable ones (a malformed provider response, an unexpected SDK bug). Making the catch-all retryable would retry failures that will never succeed; the fix is to classify the recoverable case, not to blur the bucket. + +**Wrap the flattened error in an `LlmError('TRANSPORT', { cause })` in the adapter, mirroring the DeepSeek adapter.** Rejected here: the DeepSeek adapter wraps a *pre-response* `fetch` rejection whose `cause` is still intact, so chaining preserves real detail. In the pi-ai path the terminal event's `errorMessage` is already a flattened string with no `cause` to chain, so wrapping would add a layer without recovering anything; classifying the code is the only value left to add. + +## Consequences + +- A mid-stream transport drop and a pre-terminal stream truncation now carry `TRANSPORT`, so a composed `llm-retry` policy retries them by default instead of failing the turn. +- The notice text is unchanged (`terminated` / `Anthropic stream ended before message_stop`): the cause detail is gone before the adapter sees it, so `errorChain` has nothing more to render. Only the routed `code` improved. +- Classification remains string-matching and provider-wording-dependent: a future pi-ai release that rewords these errors would silently fall back to `PI_AI_ERROR` until the patterns are updated. The `XXX` note points at the durable fix (route on a forwarded `code`/`cause`). diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md new file mode 100644 index 0000000000..6a1bb478a8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-22-pi-ai-transport-truncation-classification.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 从扁平化的消息文本中分类 pi-ai 传输层截断 + +Status: implemented + +[English](2026-07-22-pi-ai-transport-truncation-classification.md) | 中文 + +## Problem + +一次 TUI 运行的模型连接在流式输出中途断开,只浮现出一条 `terminated` 通知,而一个被截断的 Anthropic 响应则浮现出 `Anthropic stream ended before message_stop`。两者都是传输层截断——连接在提供方的终止 SSE 事件之前就已断开——然而 `dsh-llm-pi-ai` 中的 `classifyPiAiError` 对两者都不匹配,最终落入兜底的 `PI_AI_ERROR`。由于 `PI_AI_ERROR` 不在 `llm-retry` 的 `DEFAULT_RETRYABLE_CODES`(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`)中,一次可恢复的断开被当作永久性失败处理,从未被重试。 + +细节丢失发生在上游,且在适配器内无法恢复:pi-ai 在推送终止 `error` 事件之前,把捕获到的错误缩减为 `error.message`(`api/anthropic-messages.js`:`errorMessage = error instanceof Error ? error.message : JSON.stringify(error)`),丢弃了原始的 `Error` 及其 `cause` 链。undici 把可操作的 `SocketError` 携带在 `cause` 上,却只交给 fetch 包装层一个裸的 `terminated`;pi-ai 只保留了这个词。pi-ai 的 `SimpleStreamOptions` 没有暴露任何 fetch/dispatcher/client 钩子,让我们能在细节被扁平化之前自行捕获 `cause`。 + +## Decision + +- `classifyPiAiError` 识别另外两种传输层措辞,并将两者都映射为 `TRANSPORT`: + - 流式输出中途的套接字断开,呈现为裸的 `terminated`(undici)或 `Premature close`(Node 流层); + - 在终止事件之前被截断的流,每个 pi-ai 提供方各自抛出不同措辞(`Anthropic stream ended before message_stop`、`… before a terminal response event`、`… ended without a terminal event`、`Stream ended without finish_reason`),统一按 `stream ended before/without` 匹配。 +- 该分类器带有一条 `XXX(pi-ai upstream)` 注记,点名扁平化发生的位置并说明期望的修复方式:如果 pi-ai 有朝一日转发原始的 `Error` 或提供一个让我们捕获 `cause` 的钩子,就改为基于 `code`/`cause` 分类。在此之前分类仍是尽力而为的文本匹配。 +- `llm-pi-ai/README.md` 新增一条 Known-Limitations 条目,记录 pi-ai 会扁平化 cause 链,因此 harness code 是从消息文本中分类出来的。 + +分类仍然基于消息文本,因为那是 pi-ai 唯一交付的信号;`XXX` 标明它是一个权宜之计,而非期望的最终状态。 + +## Alternatives considered + +**通过 pi-ai 的 fetch/dispatcher/client 钩子捕获 `cause`。** 否决:pi-ai 0.81.1 一个都没暴露。`StreamOptions` 只提供 `onPayload`/`onResponse`;`onResponse` 在响应体流被消费之前触发,因此无法观察到流式输出中途的断开。Anthropic 路径接受一个 `client` 对象,但为拦截传输错误而为每个请求构造并注入一个提供方 SDK client,只为一个诊断字符串就越过了适配器的服务边界。 + +**把两者都保留为 `PI_AI_ERROR`,并放宽 `llm-retry` 的可重试集合。** 否决:`PI_AI_ERROR` 是真正未分类失败的兜底,其中包括不可重试的失败(畸形的提供方响应、意料之外的 SDK bug)。让兜底可重试会重试那些永远不会成功的失败;修复之道是分类出可恢复的那种情况,而不是模糊这个类别。 + +**在适配器里把扁平化后的错误包装成 `LlmError('TRANSPORT', { cause })`,仿照 DeepSeek 适配器。** 在此否决:DeepSeek 适配器包装的是拿到响应之前的 `fetch` 拒绝,其 `cause` 仍然完好,因此链式包装保留了真实细节。而在 pi-ai 路径中,终止事件的 `errorMessage` 已经是一个没有 `cause` 可链的扁平化字符串,因此包装只会加一层却恢复不了任何东西;分类出 code 是唯一还能增加的价值。 + +## Consequences + +- 流式输出中途的传输层断开和终止前的流截断现在都携带 `TRANSPORT`,因此组合出的 `llm-retry` 策略会默认重试它们,而不是让该轮次失败。 +- 通知文本不变(`terminated` / `Anthropic stream ended before message_stop`):cause 细节在适配器看到之前就已丢失,因此 `errorChain` 没有更多内容可渲染。只有被路由的 `code` 得到了改善。 +- 分类仍然依赖字符串匹配且依赖提供方的措辞:未来某个 pi-ai 版本若改写这些错误的措辞,就会静默回退到 `PI_AI_ERROR`,直到模式被更新。`XXX` 注记指向那个持久的修复方式(基于转发的 `code`/`cause` 路由)。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.i18n.yaml new file mode 100644 index 0000000000..98dfa90c4d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.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-23-tui-generic-card-markdown.md: 494ba580480fa99de54e84025a65bf4589d410f2 +2026-07-23-tui-generic-card-markdown.zh.md: 214edd00f50b88a4e8901b19dcdafc7382399831 diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md new file mode 100644 index 0000000000..494ba58048 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.md @@ -0,0 +1,29 @@ +# Agent Note: TUI generic-card Markdown rendering + +Status: implemented + +English | [中文](2026-07-23-tui-generic-card-markdown.zh.md) + +## Problem + +Tool presenters can put Markdown in generic-card content, including fenced `console` output used for background-task acknowledgements and execution errors. Rendering that content as plain text exposes the fence markers and diverges from assistant and user content in the same transcript. + +## Decision + +The TUI renders generic-card result content with its shared Markdown theme before applying the card's head-and-tail line limit. Terminal and diff cards retain their specialized plain-text renderers, and generic-card raw input remains literal because it represents tool arguments rather than presenter-authored prose. + +The shared theme hides fence syntax, retains the optional language label, and colors the fenced body as code. Rendering precedes truncation so collapsed-card line counts and boundaries describe the visible terminal rows rather than Markdown source rows. + +## Alternatives considered + +**Strip fences in the Bash presenter.** This would fix one producer while leaving generic-card Markdown from other tools unrendered and would make the presenter depend on TUI behavior. + +**Render every tool card as Markdown.** Terminal output and diffs have dedicated formatting and may contain Markdown punctuation that must remain literal. + +**Apply the collapsed-card limit before Markdown rendering.** Source-line truncation can split a fenced block and makes the visible line count differ from the count used by the card. + +## Consequences + +Generic tool cards use the same Markdown vocabulary and sanitization path as conversation content. Markdown punctuation in a generic card is interpreted rather than always displayed literally; tools that require literal terminal output use the terminal card intent. + +The focused TUI test pins hidden fences, retained language labels, and body text. The keyless terminal-state snapshot covers the behavior through an assembled TUI transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md new file mode 100644 index 0000000000..214edd00f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-23-tui-generic-card-markdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 通用卡片的 Markdown 渲染 + +Status: implemented + +[English](2026-07-23-tui-generic-card-markdown.md) | 中文 + +## Problem + +工具展示器可以在通用卡片(generic card)内容中写入 Markdown,其中包括用于后台任务确认和执行错误的围栏 `console` 输出。把这些内容按纯文本渲染会暴露围栏标记,并与同一 transcript(文本记录)中的助手内容和用户内容显示不一致。 + +## Decision + +TUI 先用共享的 Markdown 主题渲染通用卡片的结果内容,再应用卡片的头尾行数限制。终端卡片和 diff 卡片保留各自专门的纯文本渲染器;通用卡片的原始输入仍按字面显示,因为它代表的是工具参数,而非展示器撰写的行文。 + +共享主题隐藏围栏语法,保留可选的语言标签,并将围栏正文按代码配色。渲染先于截断执行,因此收起状态卡片的行数和边界描述的是可见的终端行,而非 Markdown 源文本行。 + +## Alternatives considered + +**在 Bash 展示器中剥除围栏。**这只修复一个生产方,其他工具产生的通用卡片 Markdown 仍不会被渲染,还会让展示器依赖 TUI 的行为。 + +**把每种工具卡片都按 Markdown 渲染。**终端输出和 diff 有专门的格式,且可能包含必须保持字面显示的 Markdown 标点。 + +**在 Markdown 渲染之前应用收起状态卡片的行数限制。**按源文本行截断可能从中间截断围栏块,还会让可见行数与卡片使用的行数不一致。 + +## Consequences + +通用工具卡片与对话内容使用同一套 Markdown 词汇和净化路径。通用卡片中的 Markdown 标点会被解释,而不再总是按字面显示;需要字面终端输出的工具使用终端卡片这一渲染意图。 + +聚焦的 TUI 测试固定了隐藏的围栏、保留的语言标签和正文文本。无密钥的终端状态快照通过组装后的 TUI transcript 覆盖该行为。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml index 4267e3b83f..3bd5fa6be2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.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-24-empty-model-response-is-retryable.md: f4a6373178efd5ca1ba5882fb2aaf97dffb2526b -2026-07-24-empty-model-response-is-retryable.zh.md: 4c3afe44140c029d274f34ade97803b958c6d669 +2026-07-24-empty-model-response-is-retryable.md: 3ecb106fc3a53070f66d1de351120aaf99d4d0de +2026-07-24-empty-model-response-is-retryable.zh.md: 91ce4105ebe60b71f12667ccf28ea905566353d5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md index f4a6373178..3ecb106fc3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md @@ -15,7 +15,7 @@ An adapter classifies a completed empty response as a provider-boundary failure, - `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`. - `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification). - `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful. -- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`. +- The provider-owned normal retry default includes `EMPTY_RESPONSE`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`, and `dsh-llm-retry` executes the resolved policy. Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md index 4c3afe4414..91ce4105eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md @@ -15,7 +15,7 @@ Status: implemented - `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。 - `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。 - `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。 -- `dsh-llm-retry` 把 `EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。 +- 提供方拥有的 normal 重试默认策略包含 `EMPTY_RESPONSE`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除,而 `dsh-llm-retry` 会执行解析后的策略。 检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.i18n.yaml new file mode 100644 index 0000000000..6d83cc0cf3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.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-24-tui-turn-end-stop-reason-notices.md: 7c783ce5a347b15d682dbeca03ad5355aca7950b +2026-07-24-tui-turn-end-stop-reason-notices.zh.md: 4a983525779b96c296ac2d621ca5928e7bed61c9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md new file mode 100644 index 0000000000..7c783ce5a3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.md @@ -0,0 +1,27 @@ +# Agent Note: TUI presents a reason for every turn-end kind + +Status: implemented + +English | [中文](2026-07-24-tui-turn-end-stop-reason-notices.zh.md) + +## Problem + +The TUI rendered transcript notices for `error`, `aborted`, `max-tokens`, `rejected`, and `interrupted` turn ends, but a `disposed` turn end and any plugin-added `TurnEndReasonMap` kind rendered nothing. When such a turn ended — live or replayed from a persisted log — the agent stopped working with no visible reason, breaking the product expectation that every stop is explained to the user. + +## Decision + +The `turn/end` case in `packages/ui/tui/src/index.ts` switches on the reason's discriminant and covers every kind: `completed` stays silent because the settled assistant message and its `Completed` timing header already present that outcome; `disposed` appends `Turn stopped: the agent was disposed.`; and the merge-extensible default appends `Turn ended: .` so an unknown plugin-added outcome still names why the agent stopped. All other kinds keep their existing notices. + +## Alternatives considered + +**A notice for `completed` turns too.** Rejected as noise: every ordinary response would gain a redundant line, and the assistant message plus its frozen timing header already mark the completion. + +**Suppressing the `disposed` turn-end notice live because `agent/disposed` also appends `Agent "" was disposed.`** Rejected: the two notices state different facts (this turn was cut short vs. the agent is gone), and the turn-end notice is the only one that survives replay of a persisted log, where the live `agent/disposed` emission does not recur. + +**Keeping the default branch silent (the prior behavior).** Rejected: a merge-extensible kind unknown to the TUI is exactly the case where the user has no other way to learn why the agent stopped. + +## Consequences + +- A turn never ends without a user-visible reason in the TUI: every non-`completed` `turn/end` kind appends a transcript notice, including unknown plugin-added kinds by name. +- Live disposal during a running turn shows two notices (the turn-end notice plus `agent/disposed`); a replayed log shows the turn-end notice alone. +- The `errors-and-help` snapshot pins the `disposed` and unknown-kind notices alongside the existing failure and interruption notices. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md new file mode 100644 index 0000000000..4a98352577 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-tui-turn-end-stop-reason-notices.zh.md @@ -0,0 +1,27 @@ +# Agent Note: TUI 为每种轮次结束 kind 呈现原因 + +Status: implemented + +[English](2026-07-24-tui-turn-end-stop-reason-notices.md) | 中文 + +## 问题 + +TUI 会为 `error`、`aborted`、`max-tokens`、`rejected`、`interrupted` 这几种轮次结束渲染 transcript(文本记录)通知,但 `disposed` 轮次结束和任何插件新增的 `TurnEndReasonMap` kind 不渲染任何内容。此类轮次结束时,无论实时发生还是从持久化日志回放,agent(智能体)都会在没有任何可见原因的情况下停止工作,违背了「每次停止都要向用户解释」的产品预期。 + +## 决策 + +`packages/ui/tui/src/index.ts` 中的 `turn/end` 分支按 reason 的判别字段做 switch,覆盖每一种 kind:`completed` 保持沉默,因为已定稿的助手消息及其 `Completed` 计时头部已经呈现了这一结果;`disposed` 追加 `Turn stopped: the agent was disposed.`;merge 扩展的 default 分支追加 `Turn ended: .`,让未知的插件新增结果仍能点明 agent 停止的原因。其余各 kind 保留现有通知。 + +## 备选方案 + +**为 `completed` 轮次也加一条通知。** 否决,属于噪音:每次普通响应都会平添一行冗余内容,而助手消息加上已冻结的计时头部本就标示了完成。 + +**因为 `agent/disposed` 也会追加 `Agent "" was disposed.`,就在实时场景下抑制 `disposed` 轮次结束通知。** 否决:两条通知陈述的是不同事实(前者说明这一轮被中途截断,后者说明 agent 已不复存在),而且只有轮次结束通知在回放持久化日志时得以保留,实时发出的 `agent/disposed` 不会在回放中重现。 + +**让 default 分支保持沉默(沿用先前行为)。** 否决:TUI 不认识的 merge 扩展 kind,恰恰是用户没有其他途径得知 agent 为何停止的情形。 + +## 后果 + +- 在 TUI 中,轮次结束永远不会缺少用户可见的原因:每种非 `completed` 的 `turn/end` kind 都会追加一条 transcript 通知,未知的插件新增 kind 也会按名称列明。 +- 轮次运行期间实时 dispose(资源释放)会显示两条通知(轮次结束通知加上 `agent/disposed`);回放日志则只显示轮次结束通知。 +- `errors-and-help` 快照把 `disposed` 通知和未知 kind 通知连同现有的失败与中断通知一并固定下来。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml new file mode 100644 index 0000000000..2979127ed1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.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 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md new file mode 100644 index 0000000000..2e0e9b9ca6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -0,0 +1,50 @@ +# Agent Note: Question-composer option rows are scroll content, not the slack absorber + +Status: implemented + +English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md) + +## Problem + +The question composer card is capped against the viewport (`max-height: min(60vh, 520px)`) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title. + +The cap was not the defect; the distribution of the shortfall was. `.options` is a `flex-direction: column` box whose children default to `flex-shrink: 1`, so under-allocation shrank the rows first instead of overflowing the scroll container. A row shrank to its `min-height: 42px` while `.optionCopy` kept the taller intrinsic height its wrapped copy needs (two lines for an option with a description). With `align-items: center`, the copy is then centered on a box shorter than itself and paints outside the row's border box in both directions — over the title above and the next row below. Measured on the shipped client at 900x440: 6.5px of copy outside the row box, growing to 10px at 380px tall, while `.options` reported `scrollHeight === clientHeight` and therefore never offered a scrollbar. + +Only rows whose copy wraps can reproduce it. A row whose copy fits on one line has slack between its content and its 42px minimum, so shrinking it stays invisible — which is why the pre-existing e2e fixture (options `Blue`/`Green`, no descriptions) rendered correctly at every size. + +## Decision + +`.option` and `.custom` declare `flex-shrink: 0`. + +The rows are the scroll content of a capped card; the card's overflow belongs to `.options`, which already owns `overflow-y: auto` and `min-height: 0`. Pinning the children makes the shortfall reach that scroll container instead of being absorbed by the rows, which is the behavior the cap was designed for. The alternative — letting rows shrink but keeping the copy inside them — would require clipping or ellipsizing option descriptions at exactly the sizes where the user most needs to read them. + +`.header` and `.footer` already carried `flex-shrink: 0` for the same reason at the card level; the option list's children were the missing half of that rule. + +## Alternatives considered + +**Clip or ellipsize the copy inside a shrunk row (`overflow: hidden` on `.option`).** This removes the overlap with one declaration and no layout rethink. Rejected because it trades a visible defect for a silent one: the row keeps its 42px, and the second line of an option description simply disappears at exactly the sizes where the card is tightest. The description is decision-relevant content, not decoration. + +**Drop `align-items: center` for `align-items: flex-start`.** The copy would grow downward only, so it would no longer paint over the title above. It does not fix anything: a shrunk row still overflows onto the row below, and the fix would silently change the vertical alignment of every option row at every size, including the common one. + +**Remove the card's `max-height` cap so nothing is ever squeezed.** No shortfall means no distribution problem. Rejected because the cap is what keeps the header and the footer actions on screen for a long question batch; removing it reintroduces the failure the cap exists to prevent (the composer seat is a fixed-height conversation column with `overflow: hidden`, so an uncapped card loses its own submit button instead). + +**Cap the wrapped copy at one line (`white-space: nowrap` plus ellipsis on `.description`).** Rows would never wrap, so they could never overflow when shrunk. Rejected for the same reason as clipping, plus it degrades the wide-viewport rendering — where there is ample room — to fix a narrow-viewport defect. + +## Consequences + +- A squeezed composer scrolls its option list instead of overlapping it: at 900x380 the list reports `scrollHeight` 200 against `clientHeight` 114 and offers a scrollbar, where before it reported them equal and offered none. +- Option rows keep their full wrapped copy at every viewport size. Nothing is clipped or ellipsized, and the wide-viewport rendering is unchanged (the rule only takes effect when the flex box is under-allocated). +- The card now reaches its scroll state sooner, since the shortfall is no longer partly absorbed by the rows. That is the intended behavior of the cap, and it means a short seat shows a scrollbar in cases that previously showed a silently mis-painted list. +- The scenario's recorded question is longer than it needs to be for the round trip it primarily tests. That cost is deliberate: the layout invariant is unfalsifiable without wrapping copy, and a second fixture for one CSS rule would be worse. + +## Verification + +The web e2e composer scenario asserts the invariant on the live composer at three squeezed seat heights (900x520 / 440 / 380): every option row's children stay inside the row's border box. Two guards keep the assertion from holding vacuously — at least one row must be wrapped (the only shape that overflows) and `.options` must actually be scrolling (proof the seat is genuinely capped). The scenario's recorded question now carries long option descriptions for exactly that reason; without wrapping copy the assertion cannot fail. + +Confirmed both directions against the built client: with `flex-shrink: 0` reverted the scenario fails (`scrolls: false`, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero. + +The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it. + +Reproducing the shortfall requires a short viewport, not a short container. The cap is `min(60vh, 520px)`, so shrinking the conversation column below the card's own height clips the card without under-allocating it — the rows keep their full height and nothing spills. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport. + +A stale `lib/` makes the browser lane assert against an older client than the tree, and a `pnpm run build` that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under `packages/` are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md new file mode 100644 index 0000000000..845d9ee883 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方 + +Status: implemented + +[English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 + +## 问题 + +提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 + +缺陷不在这个高度上限,而在高度不足时由谁来吸收。`.options` 是一个 `flex-direction: column` 的盒子,其子元素默认取 `flex-shrink: 1`,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 `min-height: 42px`,而 `.optionCopy` 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 `align-items: center`,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 `.options` 报告的 `scrollHeight` 等于 `clientHeight`,因此始终不会给出滚动条。 + +只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue`/`Green`,无描述)在任何尺寸下都渲染正常的原因。 + +## 决策 + +`.option` 与 `.custom` 声明 `flex-shrink: 0`。 + +在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 `.options` 承担,它本就持有 `overflow-y: auto` 与 `min-height: 0`。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。 + +`.header` 与 `.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。 + +## 曾考虑的替代方案 + +**在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。 + +**把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。 + +**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。 + +**把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 + +## 后果 + +- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 +- 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 +- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 +- 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 + +## 验证 + +Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 + +在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。 + +该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 + +要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。 + +`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.i18n.yaml new file mode 100644 index 0000000000..dfde0f9ad4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.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 .agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md +2026-07-27-tool-card-single-row-fields-inline.md: e04110ed74b68afffc6ea45fc2cb52c4063268e7 +2026-07-27-tool-card-single-row-fields-inline.zh.md: ac532ec6eb51d42a529e0816d1204cb2729a074a diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md new file mode 100644 index 0000000000..e04110ed74 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.md @@ -0,0 +1,23 @@ +# Agent Note: Tool-card single-row fields render inline + +Status: implemented + +English | [中文](2026-07-27-tool-card-single-row-fields-inline.zh.md) + +## Problem + +A tool card's title, description, cwd, and pending `$ ` echo are each one logical row. The bash tool sets the card title (and description) directly from the model's command and description, which for a multi-line bash script contain real newlines. These fields were escaped with `displayText`, which deliberately preserves `\n` as structural layout. A multi-line title therefore broke onto extra terminal rows that the card's line accounting did not reserve, so the title's later lines overwrote the description, the output, or the editor's steering hint — the card rendered as garbled, overlapping text. Removing the gutter bar (see the [copyable-transcript note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md)) made the collision visible because those rows no longer sat behind a per-line prefix. + +## Decision + +Single-row card fields use `displayInlineText` (which escapes `\n` to the literal `\x0a`) instead of `displayText`: the card title, the terminal-card `description` and `cwd` meta rows, and the pending `$ ` echo. Each stays on exactly one row, so a multi-line command can no longer break rows and collide with adjacent lines. Genuinely multi-line fields — captured command output and the `contentText` result body — keep `displayText` plus `split('\n')`, because those legitimately occupy multiple rows. + +## Alternatives considered + +- **Strip newlines from the presenter output** (in the bash tool) — hides the model's real command shape from any consumer of the view, and pushes a UI concern into the tool. The escape belongs at the single-row render site. +- **Let the title wrap to multiple rows deliberately** — a card title is a one-line identity; a wrapped multi-line title still collides with the following meta rows unless the whole card is re-laid-out, and it bloats the transcript. + +## Consequences + +- Multi-line bash commands render as a single inline title (`S=/tmp\x0aecho …`); the description, output, and exit rows below stay intact. Verified live in tmux for both the pending (`◌`) and completed (`✓`) states. +- A `multilineTerminal` tool-card case in `tui.spec.ts` asserts the inline-escaped form appears for a newline-bearing title and description. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md new file mode 100644 index 0000000000..ac532ec6eb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tool-card-single-row-fields-inline.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 工具卡片的单行字段以内联方式渲染 + +Status: implemented + +[English](2026-07-27-tool-card-single-row-fields-inline.md) | 中文 + +## Problem + +工具卡片的标题、描述、cwd 以及待执行的 `$ ` 回显各自都是一个逻辑行。bash 工具直接用模型给出的命令与描述来设置卡片标题(和描述),而对于多行 bash 脚本,这些内容包含真实换行。这些字段此前用 `displayText` 转义,而 `displayText` 会刻意保留 `\n` 作为结构性布局。于是多行标题会换到卡片行数核算未预留的额外终端行上,标题后续的行便覆盖了描述、输出,或编辑器的 steering 提示——卡片渲染成互相重叠的乱码文本。移除 gutter bar(见[可复制 transcript 的 note](../simplification/2026-07-27-copyable-transcript-no-gutter-bar.md))后,这些行不再位于逐行前缀之后,因而暴露了这一冲突。 + +## Decision + +单行卡片字段改用 `displayInlineText`(将 `\n` 转义为字面量 `\x0a`)而非 `displayText`:包括卡片标题、terminal 卡片的 `description` 与 `cwd` 元数据行,以及待执行的 `$ ` 回显。每个字段都严格保持在一行内,因此多行命令不再会换行并与相邻行冲突。真正多行的字段——捕获的命令输出与 `contentText` 结果正文——仍保留 `displayText` 加 `split('\n')`,因为它们本就应占据多行。 + +## Alternatives considered + +- **在 presenter 输出中剥除换行**(在 bash 工具里)—— 会对该视图的所有消费方隐藏模型真实的命令形态,并把 UI 关注点塞进工具。转义应发生在单行渲染处。 +- **让标题刻意换到多行** —— 卡片标题是一行式身份标识;除非重排整个卡片,多行标题仍会与其后的元数据行冲突,还会让 transcript 膨胀。 + +## Consequences + +- 多行 bash 命令渲染为单行内联标题(`S=/tmp\x0aecho …`);其下的描述、输出与退出码行保持完整。已在 tmux 中对待执行(`◌`)与已完成(`✓`)两种状态实测验证。 +- `tui.spec.ts` 中新增了一个 `multilineTerminal` 工具卡片用例,断言对含换行的标题与描述会出现内联转义后的形式。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml new file mode 100644 index 0000000000..a8472075e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.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 .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a +2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md new file mode 100644 index 0000000000..708e543ff0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff card dropped the duplicated file path + +Status: implemented + +English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) + +## Problem + +The `edit` and `write` tool cards printed the target path twice. Each tool's `presentCall`/`presentResult` returns a diff card whose title is `Edit `/`Write ` and whose single `FileDiff` carries the same `path`. The TUI's `diffLines` unconditionally rendered `palette.bold(diff.path)` as a per-file header, so a one-file edit rendered: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +The existing snapshot fixture hid the bug: it titled the edit card `Edit renderer` (no path) and gave the result two diffs, so the title never matched a diff path and the header never looked redundant. + +## Decision + +`diffLines` takes a `showPath` flag; `ToolCardComponent.renderBody` suppresses the per-file header for a diff card when there is exactly one diff and the effective card title (`resultView?.title ?? callView.title`) already contains that diff's path. Multi-file diff cards keep every per-file header. An empty or blank diff path collapses under the same `String.includes` check, which is the intended noise removal. + +The suppression lives in the TUI renderer, not in each tool's presenter, because the redundancy is a presentation concern shared by every current and future single-file diff card; the tools keep emitting the path in both the title and the diff so non-TUI consumers still get it. + +## Alternatives considered + +- Drop the path from the `edit`/`write` card titles. Rejected: the title is the scannable summary line; removing the path weakens it, and it would have to be repeated per tool. +- Always drop the per-file header. Rejected: multi-file result diffs (and any future multi-file diff card) genuinely need per-file headers. + +## Consequences + +The heuristic is a substring match, so a title that happens to contain a single diff's path suppresses the header even if the match is incidental; for the real producers the title is exactly `Verb `, so this is correct in practice. The snapshot `edit` fixture now mirrors production: one diff whose path the title names, proving the header is dropped, while multi-file header retention is covered by the `tui.spec.ts` `edit` fixture (`a.txt`/`b.txt` under an `Edit files` title). + +## Testing + +`tui.spec.ts` adds a focused case asserting the path appears exactly once for a single-diff card titled `Edit src/only.ts`. The `advanced-cards-*` keyless snapshots re-recorded to show the title line immediately followed by the diff body with no repeated path header. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md new file mode 100644 index 0000000000..863868ae70 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -0,0 +1,37 @@ +# Agent Note: TUI diff 卡片重复打印文件路径 + +Status: implemented + +[English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 + +## Problem + +`edit` 与 `write` 工具卡片会把目标路径打印两次。两者的 `presentCall`/`presentResult` 返回的 diff 卡片,标题为 `Edit `/`Write `,而其唯一的 `FileDiff` 又携带相同的 `path`。TUI 的 `diffLines` 无条件地将 `palette.bold(diff.path)` 渲染为每文件的表头,因此单文件编辑会渲染成: + +``` +✓ Edit src/foo.ts +src/foo.ts +- old ++ new +``` + +既有的快照 fixture 掩盖了这个问题:它把编辑卡片标题设为 `Edit renderer`(不含路径),并让结果包含两个 diff,于是标题从未与某个 diff 路径匹配,表头也就不显得冗余。 + +## Decision + +`diffLines` 新增 `showPath` 参数;当一个 diff 卡片只有一个 diff、且生效标题(`resultView?.title ?? callView.title`)已包含该 diff 的路径时,`ToolCardComponent.renderBody` 抑制每文件表头。多文件 diff 卡片保留全部每文件表头。空白或空路径同样落入这条 `String.includes` 判定之下,这正是有意去除的噪声。 + +抑制逻辑放在 TUI 渲染层,而非各工具的 present 方法中,因为这种冗余是所有当前及未来单文件 diff 卡片共有的展示问题;工具仍在标题和 diff 中同时给出路径,从而非 TUI 消费方依旧能拿到它。 + +## Alternatives considered + +- 从 `edit`/`write` 卡片标题中去掉路径。已否决:标题是可快速扫读的摘要行,去掉路径会削弱它,而且需要在每个工具里重复处理。 +- 一律去掉每文件表头。已否决:多文件结果 diff(以及未来任何多文件 diff 卡片)确实需要每文件表头。 + +## Consequences + +该启发式是子串匹配,因此若标题恰好包含某个单一 diff 的路径,即便是偶然匹配也会抑制表头;对真实的产出方而言标题恰为 `Verb `,故在实践中是正确的。快照 `edit` fixture 现在与生产一致:单个 diff,其路径正是标题所命名,从而证明表头被去除;而多文件表头保留由 `tui.spec.ts` 的 `edit` fixture(`Edit files` 标题下的 `a.txt`/`b.txt`)覆盖。 + +## Testing + +`tui.spec.ts` 新增一个聚焦用例,断言标题为 `Edit src/only.ts` 的单 diff 卡片中路径恰好出现一次。`advanced-cards-*` 无密钥快照已重新录制,展示标题行紧接 diff 正文、不再有重复的路径表头。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.i18n.yaml new file mode 100644 index 0000000000..c246c6fb0d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.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-27-tui-step-timing-trails-tool-cards.md: 82f46b44d3b939ca89c4508eb948ed584082d9fd +2026-07-27-tui-step-timing-trails-tool-cards.zh.md: 885b232973d20013782dee7ec1e846e7a012b01e diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md new file mode 100644 index 0000000000..82f46b44d3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.md @@ -0,0 +1,28 @@ +# Agent Note: TUI step timing trails the step's last message + +Status: implemented + +English | [中文](2026-07-27-tui-step-timing-trails-tool-cards.zh.md) + +## Problem + +The per-step timing summary (`Model wait … · Completed …`) was a child of the assistant message component, so it rendered directly under the assistant text. When a step drove tool calls, the tool cards were appended to the chat *after* the assistant message, leaving the timing line stranded above them — one message before the step's actual last output. The summary is meant to close a step, so on any tool-calling step it appeared in the wrong place. + +## Decision + +The timing summary is its own `StepTimingComponent`, no longer a child of `AssistantMessageComponent`. `StreamingAssistantComponent` owns one and exposes it as `timing`, but the renderer attaches it to the chat as a sibling that follows the assistant message. Whenever a `tool/call` or `tool/result` of the open step appends a card, `trailStreamingTiming()` moves the footer back to the tail of the chat, so it always trails the step's last message. On `step/end` the footer is completed in place — already at the tail — and stays pinned while the next step's output follows. `removeStreaming` and the reasoning-toggle rebuild detach and reattach the footer together with its streaming component. + +Event ordering makes this exact: within a step the loop appends `tool/call` and `tool/result` before `step/end`, so the footer is repositioned while `streaming` is still set, then frozen when the step ends. + +## Alternatives considered + +**Keep the timing inside the assistant message and reorder tool cards above it.** Rejected: tool cards belong after the assistant text that requested them; moving them above the assistant message to sit under the timing would misrepresent the transcript order. + +**Recompute a single trailing footer for the whole turn instead of one per step.** Rejected: a multi-step turn shows each step's own completed timing, and collapsing them would drop the per-step buckets the existing timing tests pin. + +**Reposition the footer from a `step/end`-only handler.** Rejected: tool cards render before `step/end`, so a footer moved only at step end would already be trailing but would not track a mid-step re-render, and the running (pre-completion) footer would still sit above the tool cards during streaming. + +## Consequences + +- On a tool-calling step the timing summary renders below the tool cards, both while the turn runs and after it completes; the package snapshots (`untrusted-controls`, `cordis-tools-pending`, `advanced-cards-*`, `code-mode-pending`, `dynamic-workflow-pending`, `surface-before-compaction`) and the example transcripts (`todo-plan`, `bash-terminal-card`, `code-mode`, `parallel-file-reads`, `dynamic-workflow`, `cordis-dynamic-toolchain`, `code-mode-dispatch-spill`) pin the new order. +- A unit test asserts the completed timing appears after a step's tool output; it fails on the pre-fix ordering. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md new file mode 100644 index 0000000000..885b232973 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-tui-step-timing-trails-tool-cards.zh.md @@ -0,0 +1,28 @@ +# Agent Note: TUI 步骤计时跟在该步骤最后一条消息之后 + +Status: implemented + +[English](2026-07-27-tui-step-timing-trails-tool-cards.md) | 中文 + +## 问题 + +每步的计时摘要(`Model wait … · Completed …`)原本是助手消息组件的子节点,因此直接渲染在助手文本下方。当某一步触发 tool call 时,tool card(工具卡片)会在助手消息*之后*追加到聊天区,使计时行被搁在它们上方——落在该步骤真正的最后一条输出之前一条消息处。该摘要本意是收束一个步骤,因此在任何含 tool call 的步骤上都出现在了错误的位置。 + +## 决策 + +计时摘要现在是独立的 `StepTimingComponent`,不再是 `AssistantMessageComponent` 的子节点。`StreamingAssistantComponent` 持有一个并以 `timing` 暴露它,但渲染器把它作为紧随助手消息之后的同级节点挂到聊天区。每当当前打开步骤的 `tool/call` 或 `tool/result` 追加一张卡片,`trailStreamingTiming()` 就把该页脚移回聊天区末尾,使它始终跟在该步骤的最后一条消息之后。在 `step/end` 时该页脚就地定稿——此时已在末尾——并在后续步骤的输出接续时保持钉住。`removeStreaming` 与推理开关重建会把该页脚连同其流式组件一起摘除并重新挂上。 + +事件顺序让这一点精确成立:在一个步骤内,循环会先追加 `tool/call` 和 `tool/result`,再追加 `step/end`,因此页脚是在 `streaming` 仍被设置时重新定位的,随后在步骤结束时冻结。 + +## 备选方案 + +**把计时保留在助手消息内部,改为把 tool card 排到它上方。** 否决:tool card 应位于请求它们的助手文本之后;把它们移到助手消息上方以贴在计时下方,会歪曲 transcript(文本记录)的顺序。 + +**为整个轮次重算一个末尾页脚,而非每步一个。** 否决:多步轮次会显示各步自己的完成计时,合并它们会丢掉现有计时测试所固定的每步分桶。 + +**只在 `step/end` 处理器里重新定位页脚。** 否决:tool card 在 `step/end` 之前渲染,因此仅在步骤结束时移动的页脚虽已处于末尾,却无法跟踪步骤中途的重新渲染,而且流式过程中运行态(完成前)的页脚仍会落在 tool card 上方。 + +## 后果 + +- 在含 tool call 的步骤上,计时摘要渲染在 tool card 下方,轮次运行期间与完成之后皆如此;相关包快照(`untrusted-controls`、`cordis-tools-pending`、`advanced-cards-*`、`code-mode-pending`、`dynamic-workflow-pending`、`surface-before-compaction`)与示例 transcript(`todo-plan`、`bash-terminal-card`、`code-mode`、`parallel-file-reads`、`dynamic-workflow`、`cordis-dynamic-toolchain`、`code-mode-dispatch-spill`)固定了新顺序。 +- 一个单元测试断言完成计时出现在某步骤的工具输出之后;在修复前的顺序下它会失败。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 7998dc3257..414c63211a 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -1,6 +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-06-18-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521 -2026-06-18-compaction-capability-seam.zh.md: df0cf9d9131978e608d47124ba0f0db0343ee12a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +2026-06-18-compaction-capability-seam.md: 3c219b734e148b963fb5857de89c16f28c2bd402 +2026-06-18-compaction-capability-seam.zh.md: b2c7e9720b596705b60a284e6ccf1448a782b7fc 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 a263b5e7d0..3c219b734e 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 @@ -39,7 +39,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those 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, optional `ctx.toolResultPrune` rewriting runs before summary selection; compact-basic remeasures the durable surface and skips summarization if pruning restores 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, 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 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). +Canonical provider context overflow takes a separate path. The failed step closes and `agent/request-error` receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and returns `{ kind: 'retry' }` only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, 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 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index df0cf9d913..b2c7e9720b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -39,7 +39,7 @@ Status: implemented 成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行;compact-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。 -规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误与连续重试次数,compact-basic 在强制执行一次有效且平衡的缩减前先修剪。仅当 `session.surface.replaceGeneration` 增加时,它才返回 retry;这包括没有摘要范围时仅修剪取得的进展。随后循环开启新的编号步骤,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 +规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误。compact-basic 自行持有按 agent 计的溢出次数,在强制执行一次有效且平衡的缩减前先修剪,且仅当 `session.surface.replaceGeneration` 增加时才返回 `{ kind: 'retry' }`;这包括没有摘要范围时仅修剪取得的进展。随后循环关闭失败轮次,开启新的编号重试轮次,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。 ``` assistant/message → tool/result/context/steering diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index e8e4c59638..8e25425874 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -1,6 +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-06-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622 -2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md +2026-06-24-workspace-context.md: f86e227be615c9b54e2a9013d3c7dca75d3975f0 +2026-06-24-workspace-context.zh.md: 154b5260955570e2de3c88d98286c5ea6afaa3b5 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 6acdb6241b..f86e227be6 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -10,13 +10,13 @@ Repository guidance such as `AGENTS.md` belongs in a coding session's effective Neighboring products establish useful conventions but differ in details. Codex treats `AGENTS.md` as native, Claude Code uses `CLAUDE.md` and familiar system-reminder-style user context, and opencode supports both names with one winner per directory plus lazy nested discovery. The harness needs cross-tool compatibility without loading duplicate or contradictory files from the same scope. -The lifecycle has two distinct classes of content. The initial applicable chain is stable enough to live in the request prefix and benefit from provider prefix caching. Nested files, edits, candidate switches, and removals happen after the session starts and belong in durable append-only history rather than the frozen prefix. +The lifecycle has two distinct classes of content. The initial applicable chain is injected once before the first request. Nested files, edits, candidate switches, and removals happen later and join the same durable append-only history. ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/step`, `tools/post-execute`, and the optional `ctx.fs` capability. -The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. +The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The step signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. ### File Names And Precedence @@ -26,13 +26,13 @@ Candidate entries are same-directory file names. Empty entries, `.`/`..`, and en The user-global file is fixed at `$DSH_HOME/AGENTS.md`, is not affected by either candidate list, and has no local overlay. `$DSH_HOME` defaults to `~/.dsh`, matching the harness-level home role of `~/.codex` or `~/.claude` rather than introducing a plugin-specific home. Tilde expansion and the default live in `dsh-paths` so future harness features share the same convention. -### Baseline Prefix +### Baseline Injection -On the first request of an agent-loop instance, the plugin contributes one user-role message through `agent/session-prefix`. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. +At the first `agent/step` of an agent-loop instance, the plugin injects one sourced user-role message before the request is derived. It loads the user-global file first, then finds the project root by walking upward from `agent.session.header.cwd` to a configured root marker (default `.git`), then loads one candidate from each directory from the root to the cwd. A `.git` file and a `.git` directory are both valid markers, covering linked worktrees and submodules. Without a marker, the cwd itself is the root. -The plugin prepends its contribution before `await next()` returns, so session-prefix contributions appear in plugin registration order. In the product spine workspace instructions are registered before a skills catalog and therefore appear first. The loop deep-freezes the composed prefix, logs it in `EpochHeader.messagePrefix`, and reuses it verbatim for that instance. It is request state, not `Session.deriveMessages()` history. +The injection becomes a durable `user/message` with a typed `workspace-instructions` source. Its `baseline: true` marker distinguishes the complete startup or resume baseline from later deltas, and its change list persists the included scopes and content digests. In the product spine workspace instructions are registered before the skills catalog, so their `agent/step` listener injects first. The loop drains both messages before deriving the first request. -A resumed agent creates a new loop instance and recomposes the baseline from current files, with the new prefix anchored by the resume request header. This permits current baseline content on resume without mutating a prefix already used by an earlier instance. +A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). @@ -42,21 +42,21 @@ 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 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. +Baseline and dynamic messages carry their complete system-reminder framing in `content`, and every sourced `user/message` reaches the model verbatim (there is no core wrapper to opt out of). The typed `workspace-instructions` source carries persisted state that is 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. ### Duplicate Suppression And Change Detection -Every dynamic workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. +Every workspace context event stores versioned metadata with `{ action, scope, path, digest? }`, where `digest` is SHA-1 over the loaded content. Baselines additionally carry `baseline: true`. The model-facing prompt has no HTML comments, hidden markers, or headings that are parsed back into state. -At reconciliation time the plugin scans plugin-owned `context/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `context/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. +At reconciliation time the plugin scans workspace-sourced `user/message` events and derives the latest state for each visible scope. A short per-session pending map begins only after the immutable top-level `tools/result` proves an `additionalContexts` entry survived every post-execute listener, then covers the interval before the loop appends that context to the log. Each entry records the open `{ turn, step }`: an equal durable `user/message` at or after its sequence boundary confirms and removes it, while a matching `step/end` arriving first means the loop discarded its context buffer, so the plugin removes both the pending entry and its version-cache fast path. A nested Code Mode result stages its changes under the parent's opaque execution token so repeated sub-dispatches in one run do not duplicate them; the parent result rolls that provisional state back and commits only contexts retained by outer policy. An unchanged path and digest is suppressed. A logged removal is a tombstone, so a reappearing candidate becomes a new `set`. Resume works from persisted metadata. If compaction removes an instruction event from the visible surface, that state no longer suppresses a later load, matching the fact that the model can no longer see it. Only changes actually included under the byte budget enter metadata or pending state, so an omitted file remains eligible on a later touch. -The frozen baseline keeps an in-memory path/digest map for comparison. A later successful filesystem touch appends baseline edits or removals as dynamic messages; it never rewrites the prefix. During resumed prefix composition the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. +The initial baseline's typed changes are comparison state only while its event remains in the visible session surface. A later successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends baseline edits or removals as dynamic messages; it never rewrites the original event. The in-memory scope marker and provider-version cache only select and accelerate probes, so neither can suppress context the model no longer sees. During resumed baseline preparation the plugin also reconciles visible dynamic scopes, so nested changes made while the agent was offline can append an update before the first resumed request. -There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed prefix composition. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. +There is intentionally no watcher. Detection occurs at the next successful structured filesystem touch or resumed baseline preparation. A provider failure produces no removal; absence is only accepted when all configured candidates in that scope were probed successfully. ### Byte Budget And Bounded Reads @@ -68,7 +68,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc **Use a global `ctx.systemPrompt.section()`.** Rejected because one Cordis context can host sessions with different cwd values, while repository-owned text is lower-authority context rather than top-authority provider system content. -**Inject the baseline on every `agent/pre-step`.** Rejected because repeated history injection wastes tokens, complicates duplicate state, and prevents a structurally stable provider prefix. Prefix composition gives a frozen, logged, per-instance baseline while dynamic append-only messages handle changes. +**Inject the baseline on every `agent/step`.** Rejected because repeated history injection wastes tokens and complicates duplicate state. A per-mount session guard gives one visible baseline event while it remains on the surface; dynamic append-only messages handle changes and compaction re-arming. **Load both `AGENTS.md` and `CLAUDE.md` in one directory.** Rejected because repositories in transition commonly duplicate guidance across both files. Ordered candidates make precedence explicit and configurable. @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, 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. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through injected messages and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index f165d08108..154b526095 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -10,13 +10,13 @@ Status: implemented 相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。 -生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。 +生命周期中有两类截然不同的内容。初始适用文件链在第一次请求前一次性注入。嵌套文件、编辑、候选项切换和移除发生在其后,进入同一份持久的仅追加历史。 ## 决策 -该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 +该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/step`、`tools/post-execute` 和可选的 `ctx.fs` 功能。 -插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 +插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。步骤信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。 ### 文件名与优先级 @@ -26,13 +26,13 @@ Status: implemented 用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。 -### 基线前缀 +### 基线注入 -agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 +在 agent loop(智能体循环)实例的第一个 `agent/step`,插件会在派生请求前注入一条带来源的 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。 -插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。 +该注入成为一条持久 `user/message`,并携带带类型的 `workspace-instructions` 来源。其 `baseline: true` 标记将完整的启动或恢复基线与后续增量区分开来,变更列表则持久保存已纳入的作用域和内容 digest。在产品主干中,工作区指令的注册先于 skill 目录,所以其 `agent/step` 监听器先注入。循环会在派生第一次请求前 drain 这两条消息。 -恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。 +恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 @@ -42,21 +42,21 @@ agent loop(智能体循环)实例的第一次请求会让插件通过 `agent 内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 -动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。 +基线消息和动态消息都在 `content` 中携带完整的 system-reminder 框架;每条带来源的 `user/message` 都逐字抵达模型,核心层不会再添加可选择退出的包装。带类型的 `workspace-instructions` 来源携带持久化状态,该状态绝不会渲染给模型。 shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。 ### 重复抑制与变更检测 -每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 +每个工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。基线还会额外携带 `baseline: true`。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。 -协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 +协调时,插件扫描带工作区来源的 `user/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `user/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。 -路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 +路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。 -被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 +只有当初始基线事件仍在可见会话表层中时,其类型化变更才用作比较状态。后续成功的文件系统触碰会在压缩后重新添加未变化的基线 scope,或把基线编辑或移除操作追加为动态消息;它绝不重写原始事件。内存中的 scope 标记和提供方版本 cache 只用于选择探测对象并加速探测,因此二者都不能抑制模型已无法看见的上下文。恢复时准备基线的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。 -系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 +系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的基线准备。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。 ### 字节预算与有界读取 @@ -68,7 +68,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, **使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。 -**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。 +**在每次 `agent/step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token,并使重复状态复杂化。逐挂载会话防护会在基线事件仍留在表面期间提供一条可见基线事件;动态仅追加消息负责处理变更和压缩后的重新启用。 **在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。 @@ -78,7 +78,7 @@ shell 命令不会触发发现。本地 bash 调用会启动全新的 shell, ## 影响 -工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。 +工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始、嵌套与变更指令都保持持久且可回放。通用的 session/agent 上下文契约通过注入消息与工具执行后的 `additionalContexts` 数组携带带类型的来源数据,而不会把条目展平。 仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 1ad47cd1f6..8d57b6fdcc 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -1,6 +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-06-30-hook-bridges.md: c207b6901155925215548676364e903f5de2f29b -2026-06-30-hook-bridges.zh.md: d396279d7ed1991536da2cea39e2aec5e50960c2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md +2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe +2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index c207b69011..99c6b1941a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) ## Problem -The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). +The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. @@ -27,7 +27,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | `agent/prompt-submit` | `deny`→`block`; context-only→delegate+fold | `block`→`block`; context-only→delegate+fold | | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | -| `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | +| `agent/turn-stopping` | blocking Stop → next-step steering | same | | `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | unsupported by this bridge | | `subagent/end` (emit) | observe-only | unsupported by this bridge | @@ -35,7 +35,9 @@ The CC bridge's `ask` result is a real permission path, not a terminal bridge de ### Context source is always the plugin (the mislabel guard) -`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }`, so every bridge `inject()` and `HookContext` passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `context/message.source` as the plugin rather than the user. +Every bridge `inject()` and additional-context input explicitly passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `user/message.source` as the plugin rather than the user. + +`UserPromptSubmit` runs during admission, before any turn opens. It therefore writes no turn-scoped `hook/invoked` / `hook/result` pair: a block leaves no transcript, while allowed additional context is durably represented by its sourced `user/message`. The Codex payload still receives the candidate next `turn_id`; rejection does not consume that number. ### Adding context is not a veto — delegate, then prepend @@ -57,13 +59,13 @@ Hooks run in the agent's session workspace, so relative paths target the user's - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + tool presentation, so an honest rewrite is a design unit, not a field. - **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking lands. -- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. +- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; mid-turn requests record the halt in `hook/result`, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). - **Session-start / subagent-start context is best-effort (`TODO(session-start-gating)`).** Both hooks run detached from startup, so their context is injected when ready but may miss the first request or a short-lived child. Guaranteeing first-request delivery requires an awaited startup seam. ## Alternatives considered -**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in a deterministic order in the session log, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter. +**Concurrent per-point hook execution.** The reference engines run a point's matched hooks concurrently and fold the results. These bridges run them **serially** (`await` per hook inside the match loop) and fold with the same most-restrictive merge. Serial is deliberate: for turn-scoped points it keeps each hook's `hook/invoked`/`hook/result` pair adjacent and in deterministic order, and the fold is order-independent for the decision (`deny > ask > allow`) so the outcome matches. The cost is latency (hook *N* waits for hook *N−1*) and that per-hook timeouts are not overlapped — acceptable for the hook counts real configs use; revisit if a config ever fans out enough for the wall-clock to matter. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index d396279d7e..11ed3a5d17 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation`、`subagent/start`、`subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 +harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/prompt-submit`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 @@ -27,7 +27,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note]( | `agent/prompt-submit` | `deny`→`block`;仅上下文→delegate+fold | `block`→`block`;仅上下文→delegate+fold | | `tools/pre-execute` | `deny`→`deny`;`ask`→`ask` | `block`→`deny`(无 allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback;仅上下文→delegate+fold | 同上 | -| `agent/turn-continuation` | 阻塞的 Stop → `continue`(reason = 下一步 steering(中途引导)) | 同上 | +| `agent/turn-stopping` | 阻塞的 Stop → 下一步 steering(中途引导) | 同上 | | `subagent/start`(emit) | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 | | `subagent/end`(emit) | 仅观察 | 本桥接不支持 | @@ -35,7 +35,9 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 ### 上下文来源始终是插件(误标签防护) -`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()` 和 `HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。 +每个桥接的 `inject()` 和 additional-context 输入都显式传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `user/message.source` 为插件而非用户。 + +`UserPromptSubmit` 在准入阶段运行,早于任何轮次开启。因此它不写入任何轮次范围的 `hook/invoked` / `hook/result` 对:阻止不会留下 transcript(文本记录),而被允许的额外上下文由其带来源的 `user/message` 持久呈现。Codex payload 仍会收到候选的下一个 `turn_id`;拒绝不会消耗该编号。 ### 添加上下文不是否决——先 delegate,再 prepend @@ -57,13 +59,13 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 ` - **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和工具展示共同读取,诚实的重写是一个设计单元,而非一个字段。 - **Stop 循环防护**(`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。 -- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。 +- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;轮中请求会将停止请求记录在 `hook/result` 中,钩子在此期间保留其逐点效果(决策/上下文)。 - **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。 - **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达,需要一个 awaited 的启动 seam。 ## 曾考虑的替代方案 -**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。 +**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:对轮次范围的拦截点,它使每个钩子的 `hook/invoked`/`hook/result` 对相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N−1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml index ff40078df0..5845297101 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml @@ -1,6 +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-06-30-interception-seams.md: 81799f4c6e3e7a4c6b9605cd97f5728b99d11995 -2026-06-30-interception-seams.zh.md: 65ae16842c632641e7ac65908162f4784dc6e1e0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-interception-seams.md +2026-06-30-interception-seams.md: 4658983e1f098ecd199eecec4408e7c2f134cbf7 +2026-06-30-interception-seams.zh.md: 13b7c56829412773111fcf6d75cc717c51d49c7b 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 81799f4c6e..4658983e1f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md @@ -16,9 +16,9 @@ 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, signal, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. The explicit turn signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` appends a durable `prompt/blocked` and rejects that zero-step turn. +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for one claimed queued message before the loop opens a turn or appends `user/message`. The explicit admission signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` discards the candidate without creating session history. -**`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. +**`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn. ### The tool pipeline gives each phase one kind of authority @@ -33,15 +33,13 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, invalid canonical value, renderer/projector, non-JSON presentation, 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; definition-owned final content invariants also cover outer pipeline and candidate-materialization failures; and a final observer sees the execution-local canonical value beside exactly the presentation fields the session log can persist. The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/projection and durability rules. -**`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 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. +1. **Run prompt policy before opening the turn.** A blocked prompt creates no turn or durable event. On allow, the loop stages the rewritten prompt followed by every returned `additionalContexts` entry, opens the turn, and drains that outbox before the first step. Each claimed ordinary-send item is the sole direct prompt in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). -2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `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 accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. 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. +2. **Post-tool `additionalContexts` and asynchronous injections enter the active-batch FIFO and append when that batch settles.** `content`/`feedback` shape the result `execute()` returns, but each context is a separate sourced `user/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 accepts it into the same FIFO as `agent.inject()` calls made during execution. The FIFO appends after every recorded result when the batch settles, including before an interrupted turn closes. 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. -3. **A forced `continue` `reason` is enqueued through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt (matching the existing `hasSteering` force-continue override). +3. **A stopping listener requests continuation through the steering channel**, so the next step's top-of-loop drain records it as steering for the continued turn — next-*step* steering within the SAME turn, not a next-*turn* prompt. ### Pre-tool input rewrite is a separate consistency decision @@ -49,7 +47,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Boundaries -The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`, while terminal monotonic stopping is owned separately by `agent/turn-stop`. +The seam package does **not** declare `hook/*` session events (the durable hook-invocation log); those belong to `dsh-hook-protocol`, because a native plugin uses typed decisions without an external hook log. The native-plugin integration test (`packages/core/agent-loop/tests/interception.spec.ts`) composes the seams through the real loop with no `hook/*` protocol. Compaction (`PreCompact`/`PostCompact`), Notification, and Codex `PermissionRequest` remain outside this decision. The [approval seam](2026-07-06-approval-seam.md) resolves `ask` decisions through `ctx.approval`; terminal monotonic stopping is expressed by tool-result data, while `agent/turn-stopping` is the last chance to steer another step. ## Alternatives considered @@ -58,4 +56,4 @@ The seam package does **not** declare `hook/*` session events (the durable hook- ## Consequences -The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, prompt-submit, post-tool context buffering, and continuation; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge maps `rejected` turns to its `cancelled` codec value, while hook-driven snapshots verify the observable bridge behavior end to end. +The canonical interception surface is uniformly typed without giving every extension the same power: hooks return decisions, execution wrappers wrap, terminal guards only deny, and final observers only observe. The loop owns session-start, pre-turn prompt admission, post-tool context buffering, and stopping; `dsh-tools` owns identity sealing and the five-phase execution pipeline. Their contracts are documented in [architecture.md](../../../../docs/architecture.md), package READMEs, [core interception decisions](../../../../docs/core-data-structures/core.md#interception-decisions), and [tool structures](../../../../docs/core-data-structures/tools.md). The ACP bridge settles an admission rejection as `cancelled` after the agent becomes idle with no owned turn, while hook-driven snapshots verify the observable bridge behavior end to end. diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md index 65ae16842c..13b7c56829 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md @@ -16,9 +16,9 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 **Agent 事件**(`dsh-agent`): - `agent/session-start(agent, source)` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。 -- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,在轮次唯一取得所有权的排队消息追加为 `user/message` 之前触发。显式轮次 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会追加一条持久的 `prompt/blocked`,并拒绝这个零步骤轮次。 +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall,针对一条取得所有权的排队消息触发,早于循环开启轮次或追加 `user/message`。显式准入 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会丢弃该候选消息,不产生会话历史。 -**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision`。`{action:'continue', reason?}` 可携带面向模型的内容和来源,记录为同一轮次内的下一步 steering(中途引导)——与 `/goal` step-end-steer 模式互为类型化孪生。它不是 `context/message`,因此其类型不提供持久上下文元数据。 +**`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的面向模型的内容(steering,中途引导);循环随后重新读取 outbox,继续执行或关闭轮次。 ### 工具流水线为每个阶段赋予一种权限 @@ -33,15 +33,13 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果,而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由定义拥有的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。 -**`TurnEndReason.rejected`**(`dsh-session`):取得所有权的提示词被 `prompt-submit` 阻止的零步骤轮次。 - ### 三个承重的循环决策 -1. **在提示词策略之前开启轮次。** 被阻止的提示词成为零步骤的 `rejected` 轮次,保持封闭性并为 ACP(Agent Client Protocol)提供持久的终结事件。否决记录 `prompt/blocked`(含原始提示词和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。 +1. **在开启轮次之前运行提示词策略。** 被阻止的提示词不会创建轮次,也不产生持久事件。允许时,循环先暂存重写后的提示词,再暂存每个返回的 `additionalContexts` 条目,然后开启轮次并在第一个步骤之前排空该 outbox。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中唯一的直接提示词。 -2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是独立的 `context/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 +2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO,并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是一条独立的带来源 `user/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()` 在 `ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts,只暴露阻止 decision 显式提供的上下文。 -3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词(与现有的 `hasSteering` 强制继续覆盖一致)。 +3. **stopping 监听器通过 steering 通道请求继续执行**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering,而非下一*轮次*的提示词。 ### 工具执行前输入重写是一个独立的一致性决策 @@ -49,7 +47,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那 ### 边界 -seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision,而终结性的单调停止由 `agent/turn-stop` 独立负责。 +seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam,不涉及 `hook/*` 协议。压缩(compaction)(`PreCompact`/`PostCompact`)、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision;终结性的单调停止由工具结果数据表达,而 `agent/turn-stopping` 是引导再执行一步的最后机会。 ## 曾考虑的替代方案 @@ -58,4 +56,4 @@ seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志); ## 后果 -规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、工具执行后上下文缓冲和 continuation;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。 +规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision,执行包装层做包装,终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、轮次前的提示词准入、工具执行后上下文缓冲和 stopping;`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接在 agent 空闲且不再拥有轮次后,将准入拒绝结算为 `cancelled`,而钩子驱动的快照端到端验证可观测的桥接行为。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index ee6efc69f4..c00293eb69 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -1,6 +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-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 -2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md +2026-07-06-sandbox.md: b9c410e4498a565a7414085fddfb74856592da66 +2026-07-06-sandbox.zh.md: 876870a12f1205b4f2cc2c8c13b5e7ec815a1569 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index c688387319..b9c410e449 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -165,7 +165,7 @@ What shipped pins — the tiers in Testing hold each: - A resumed session's overrides apply with no catch-up state; a default changed while the process was down is narrated before the session's first new request, attributed to the operator. - Two concurrent sessions never see each other's state or notices. - Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. -- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and capability-owned policy resolution. +- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/step`, `agent/prompt-submit`, and capability-owned policy resolution. Costs and accepted limits: diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index d84df9b06b..876870a12f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 @@ -165,7 +165,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - 恢复的会话的覆盖直接生效,无需追赶状态;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。 - 两个并发会话永远看不到彼此的状态或通知。 - 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 -- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/pre-step`、`agent/prompt-submit` 和由能力拥有的策略解析。 +- `agent-loop` 未被触及——一切搭载 `systemPrompt.section`、`SessionEventMap` 合并、`agent.inject()`、`agent/step`、`agent/prompt-submit` 和由能力拥有的策略解析。 代价与已接受的限制: diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml index e189661f67..7ea57fe142 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.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-07-session-prefix.md: 75113952fc5f3df8da1580d42ed2a385b6135fe8 -2026-07-07-session-prefix.zh.md: e38bf09298296203b275d6d66a62ef17be7c045d +2026-07-07-session-prefix.md: df007012165da2da9b7de4bd9ae83534e45a439d +2026-07-07-session-prefix.zh.md: d0c574706352bfaad9d6b462a7866e509976f84e diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md index 75113952fc..df00701216 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md @@ -2,6 +2,8 @@ Status: implemented +The request-only prefix seam described below was later removed by the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md). Current producers inject durable sourced `user/message` context at `agent/step`; this record preserves the earlier design and its trade-offs. + English | [中文](2026-07-07-session-prefix.zh.md) ## Problem @@ -12,7 +14,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w ## Decision -`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). +`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([agent-loop source](../../../../packages/core/agent-loop/src/)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). Three properties carry the design: diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md index e38bf09298..d0c5747063 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md @@ -2,6 +2,8 @@ Status: implemented +下文所述的仅请求前缀 seam 后来已被[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)移除。当前的生产方在 `agent/step` 时注入持久的带来源 `user/message` 上下文;本记录保留了早先的设计及其权衡。 + [English](2026-07-07-session-prefix.md) | 中文 ## 问题 @@ -12,7 +14,7 @@ Status: implemented ## 决策 -`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([agent-loop 源码](../../../../packages/core/agent-loop/src/))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 三个属性承载了这一设计: diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index f91e3b5d42..421933e57a 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -1,6 +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-08-self-referential-cordis-toolset.md: 80bffa3a2a959939f18fd1d3422607cf61895fc7 -2026-07-08-self-referential-cordis-toolset.zh.md: 2ec79037045fdb040cccf31699789abdd3a12db2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +2026-07-08-self-referential-cordis-toolset.md: 40934fe0e2975c4e068df6ef8f31ed7921df3230 +2026-07-08-self-referential-cordis-toolset.zh.md: 13662b9359aa85895ce85391ebd5a5902cc451cc diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 80bffa3a2a..40934fe0e2 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,19 +12,19 @@ First, model-written registration must be validated where it happens: a malforme ## Decision -The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again. +The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. -The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a mount can call `ctx.bash` to run commands with the host executor's privileges and can reach the real filesystem and web services. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. +The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. ### The three tools | Tool | Contract | |---|---| -| `cordis_inspect` | Read-only report over the live runtime, one Markdown section per `what` value (omit `what` for all sections). An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. Never mutates. | -| `cordis_mount` | Evaluates `code` (the body of an async JavaScript function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted as a child of the `cordis-dynamic` group fiber and tracked under a fresh id (`dyn-1`, `dyn-2`, …). | -| `cordis_unmount` | Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. | +| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_mount`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. | +| `cordis_mount` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). | +| `cordis_unmount` | Unmounts one `cordis_mount` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. | -`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. +`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_mount` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_unmount`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering. ### Sandbox semantics @@ -36,9 +36,11 @@ Mount code crosses the vm boundary through three controls. Dual-realm `instanceo The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. -### The dynamic group and mount lifecycle +### The internal group and temporary-Plugin lifecycle -All dynamic mounts are children of one `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles reload and unload. Mounting awaits settlement; startup failure disposes the fiber before returning an error. A settled pending mount remains visible with its missing injections. `cordis_unmount` awaits the mount fiber's disposal. +Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal. + +Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ### Cross-mount composition via provide/inject @@ -52,9 +54,9 @@ Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` ( ### Configuration, rendering, and observability -The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of mount-code evaluation. Tool names, the `cordis-dynamic` group name, and the `dyn-` id prefix are structural vocabulary and stay fixed. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md) (`cordis_inspect` a `read`, `cordis_mount` an `execute` carrying the code as `rawInput`, `cordis_unmount` a `delete`), with no `presentResult` overrides. +The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_mount`, and `cordis_unmount`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, mount is `execute` carrying code as `rawInput`, and unmount is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` plus one shared Cordis accent; the mount row retains the shared JavaScript expansion and syntax highlighting. -Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own `tool/call` / `tool/result` pair, which the loop logs, and the changed tool set a mount induces is logged by the full changed request header the loop emits when schemas change between steps. There is deliberately no `cordis/mount` provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins. +Model-visible ⟺ logged holds with no new session event type: mount and unmount are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 2ec7903704..13662b9359 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,19 +12,19 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 cordis 运行时:审视它、将模型编写的插件挂载进去、再将其释放。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 -vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 +vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 ### 三个工具 | 工具 | 契约 | |---|---| -| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。精确的 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。从不产生变更。 | -| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数的函数体);代码必须 `return` 一个 cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新 id(`dyn-1`、`dyn-2`……)跟踪。 | -| `cordis_unmount` | 按 id 释放一个动态挂载,并等到释放达到完全停稳后才返回——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 | +| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_mount` 创建的临时 Plugin。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 | +| `cordis_mount` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的 Plugin 挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 | +| `cordis_unmount` | 按 id 卸载一个 `cordis_mount` 临时 Plugin,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、配置或已安装的 Plugin。 | -`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber,非活跃的所有者会被标记)、`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`(挂载表:id、名称、状态、提供的服务、等待的服务)、`api`(来自生成目录的活跃服务签名及其引用的类型形状)和 `events`(harness 事件及其分发模式和签名)。宽泛的 `api` 和 `events` 报告省略完整 JSDoc 以保持紧凑;精确 `name` 会返回一个服务或事件,以及其原始方法/声明 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../../docs/tool-catalog.md)是其完整呈现。 +`cordis_inspect` 的段落是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活 Plugin fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_mount` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时 Plugin 可跨后续 turn 保持活跃,并在 `cordis_unmount`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他段落不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。[生成的工具目录](../../../../docs/tool-catalog.md)完整呈现面向模型的调用契约。 ### 沙箱语义 @@ -36,9 +36,11 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 边界将无歧义的 JSON-Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`;无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。 -### 动态分组与挂载生命周期 +### 内部分组与临时 Plugin 生命周期 -所有动态挂载都是工具插件下方 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理重载和卸载。挂载会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的释放完成。 +每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_unmount` 等待 Plugin fiber 的释放完成。 + +临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 ### 通过 provide/inject 实现跨挂载组合 @@ -52,9 +54,9 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 ### 配置、渲染与可观测性 -该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect` 为 `read`,`cordis_mount` 为 `execute` 并将代码作为 `rawInput` 携带,`cordis_unmount` 为 `delete`),不覆盖 `presentResult`。 +该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片:inspect 为 `read`,mount 为携带代码 `rawInput` 的 `execute`,unmount 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin` 以及统一的 Cordis 强调色;mount 行仍使用共用的 JavaScript 展开视图和语法高亮。 -「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时发出的完整变更 request header 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。 +「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:mount 与 unmount 通过已记录的 `tool/call` / `tool/result` 对可见,工具集变化由 schema 在 step 间变化时发出的完整 request header 记录。临时 Plugin 属于进程内存,而非 session 状态:恢复持久化 session 只会重建对话历史,绝不会重新创建它们。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 2e7b5b1d58..f756be1482 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.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-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c -2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac +2026-07-10-agent-session-identity-and-log-location.md: 7b51ae41ac00c12a496940c891092580003646fa +2026-07-10-agent-session-identity-and-log-location.zh.md: 2574e1327f424069cdff68ef9b8de20c490077f0 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index a55bf276bf..7b51ae41ac 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -40,7 +40,7 @@ The registry rebuilds a trusted overlay for every foreground and background bash Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. -The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. @@ -82,6 +82,6 @@ A keyless full-loop integration drives the real agent loop, JSONL persistence, t ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 84b8b22187..2574e1327f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -40,7 +40,7 @@ interface SessionPersistence { 会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 -bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 @@ -82,6 +82,6 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 4c73d590af..34e6c14df6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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 .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b -2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477 +2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8 +2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 43c87bb159..4fff174272 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -80,6 +80,8 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle` Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. +Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. + `node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application. ### Model-visible output and durability diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 8afc210344..88e7a0bc4b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 + `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 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 6d44658ecc..3df1059e1f 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 @@ -1,6 +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-dedicated-full-screen-tui-front-door.md: 8d7c7b00c8d9b15ea3f2419ed44ca88209e60dad -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 49e9541c9f98c9f3beba11945ff452fc38bd9ede +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 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 8d7c7b00c8..a3f3d6b51e 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 @@ -20,9 +20,9 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, including fenced code with hidden Markdown markers, a dim optional language label, and a code-colored body, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. 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 49e9541c9f..6a0c2f1281 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 @@ -20,9 +20,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理(其中围栏代码块隐藏 Markdown 标记、保留一个暗色的可选语言标签,并使用代码配色的正文)、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 `/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 9beb453252..6cf16c04ad 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-model-facing-goal-tools.md: 2c53a7658e213dee4fecc93709244f97b821aca0 -2026-07-19-model-facing-goal-tools.zh.md: aa7b5ea14b7afff88819f0efa35c5f2d5e2e933e +2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 +2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index 2c53a7658e..bc4305af80 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,13 +22,13 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. -An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding. +An autonomous goal round that successfully reports completion or blocking marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority Every call requires an `exec.agent` that is the exact running object in `AgentRegistry`, is the current inherited driver initiator, and has an open turn. These are execution-time checks and cannot be bypassed by prompt injection or hand-authored tool arguments. -Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: `Agent.send()` and `steer()` default an omitted source to `{ kind: 'user' }`, so non-human producers must label their own content. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. +Create, edit, pause, and resume additionally require an accepted user message or user steering event in the current turn of a runtime-root agent. Root ownership is derived from the live agent graph rather than durable fork ancestry: a resumed fork can receive direct human authority, while a live child remains a subagent and cannot mutate these states. User source is a host attestation: every `Agent.send()` or `steer()` input requires an explicit source, so the host labels direct human content `{ kind: 'user' }` and non-human producers label their own provenance. The runtime proves provenance, not whether the human's wording semantically warrants creation or resumption; that interpretation remains with the model. Complete and blocked accept either direct-human authority or the exact current goal round. Goal-round authority requires a goal-sourced `user/message` whose goal id, revision, and round all equal the folded current goal. It grants only the two terminal reports. Direct human authority may stop a goal immediately. diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index aa7b5ea14b..b07f62aa52 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,13 +22,13 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering(转向)仍可参与普通的继续执行折叠。 +自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 每次调用都要求存在 `exec.agent`,且它必须是 `AgentRegistry` 中完全相同的运行中对象、当前继承的驱动发起者,并处于开放轮次内。这些检查在执行时进行,不能通过提示词注入或手写工具参数绕过。 -创建、编辑、暂停与恢复还要求运行时根智能体的当前轮次已经接纳一条用户消息或用户 steering(转向)事件。根所有权来自实时智能体图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子智能体则仍然是子智能体,不能改变这些状态。用户来源是宿主的证明:`Agent.send()` 和 `steer()` 会把省略的来源默认为 `{ kind: 'user' }`,因此非人类生产者必须标注自己的内容。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 +创建、编辑、暂停与恢复还要求运行时根 agent 的当前轮次已经接纳一条用户消息或用户 steering 事件。根所有权来自实时 agent 图,而非持久的 fork 祖先关系:恢复后的派生会话可以接收新的直接人类权限,实时子级则仍是 subagent,不能改变这些状态。用户来源是宿主的证明:每个 `Agent.send()` 或 `steer()` 输入都必须显式提供来源,因此宿主把直接人类内容标为 `{ kind: 'user' }`,非人类生产者则标注自己的来源信息。运行时证明来源,而不判断人类措辞在语义上是否足以创建或恢复目标;该解释仍由模型完成。 完成与阻塞既接受直接人类权限,也接受准确的当前目标回合。目标回合权限要求存在一条来源为目标的 `user/message`,其中目标 id、修订号和回合都与折叠后的当前目标相等。它只授予这两种终止报告权限。直接人类权限可以立即停止目标。 diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml index 8f948d3d45..b354999813 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-same-session-goal-round-driver.md: d23af9a9b05d60d2dccad095455524844f1185b9 -2026-07-19-same-session-goal-round-driver.zh.md: f4f0cd6fd575d14427025bdbd8d10bc90e25f780 +2026-07-19-same-session-goal-round-driver.md: 0e6be9fe3109336d47867ab52c585dc267309fb4 +2026-07-19-same-session-goal-round-driver.zh.md: cfd9d1aa8cbc3c17cd046df4f57a8f79a6877f5c diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md index d23af9a9b0..0e6be9fe31 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md @@ -24,7 +24,7 @@ When an agent is idle, has no competing queued work, and its current goal is `ac The `agent/prompt-submit` waterfall is the admission fence. A positive goal source is allowed only when it exactly matches the driver's pending identity and content, the live goal still has that id and revision, activation remains armed, and the round is still the next number. The plugin checks once before delegating and again after downstream hooks return. This second check prevents an async hook from editing or pausing the goal while still admitting the old prompt. -Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation becomes a durable `prompt/blocked` plus zero-step rejected turn, but the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. +Only the resulting `user/message` is an admitted round and advances the goal fold. A stale reservation is discarded before a turn opens; the driver marks it stale and does not charge the round. A downstream policy rejection that is not caused by staleness blocks the goal rather than retrying around policy. ### Human work and revision races @@ -43,7 +43,6 @@ The driver classifies one closed goal-owned turn as follows: | `error` with code `RATE_LIMIT` or `QUOTA` | block with code `usage-limited` | | other `error` | block with code `turn-error` | | `max-tokens` | block with code `max-tokens` | -| non-stale `rejected` | block with code `prompt-rejected` | | failed durability checkpoint | disarm without changing durable phase | | `disposed` or `interrupted` | disarm | | plugin-added unknown result | block for inspection | diff --git a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md index f4f0cd6fd5..cfd9d1aa8c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.zh.md @@ -24,7 +24,7 @@ Status: implemented `agent/prompt-submit` 瀑布是接纳栅栏。正数目标来源只有在完全匹配驱动器待处理的身份和内容、实时目标仍具有相同 id 与修订号、激活态仍为 armed,并且该回合仍是下一个编号时才会获准。插件在委托下游监听器前检查一次,在下游返回后再检查一次。第二次检查防止异步钩子编辑或暂停目标后,旧提示词仍被接纳。 -只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。过期预留会生成持久的 `prompt/blocked` 和零步骤 rejected 轮次,但驱动器会把它标记为过期,不消耗回合数。若下游策略拒绝并非由过期导致,目标会进入 blocked,而不会绕过该策略自动重试。 +只有最终产生的 `user/message` 才是已接纳目标回合,并推进目标折叠。陈旧预留会在轮次打开前被丢弃;驱动器会把它标记为陈旧,不消耗回合数。若下游策略拒绝并非由陈旧状态导致,目标会进入 blocked,而不会绕过该策略自动重试。 ### 人类工作与修订竞争 @@ -43,7 +43,6 @@ Status: implemented | 代码为 `RATE_LIMIT` 或 `QUOTA` 的 `error` | 以 `usage-limited` 代码阻塞 | | 其他 `error` | 以 `turn-error` 代码阻塞 | | `max-tokens` | 以 `max-tokens` 代码阻塞 | -| 非过期的 `rejected` | 以 `prompt-rejected` 代码阻塞 | | 持久检查点失败 | 解除激活,但不改变持久阶段 | | `disposed` 或 `interrupted` | 解除激活 | | 插件新增的未知结果 | 阻塞并等待检查 | diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 6f3f732cc1..39689747e1 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml @@ -1,6 +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-code-mode-typed-tool-returns.md: 3d8642d66baf521f22dfb1ea0ef3e64683f918d4 -2026-07-20-code-mode-typed-tool-returns.zh.md: fea1be3e236c0ccba729e449ab9714ed497d900a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +2026-07-20-code-mode-typed-tool-returns.md: 2081d8161f0ee14493a09762b18ec7d9d07ea3c4 +2026-07-20-code-mode-typed-tool-returns.zh.md: 0fa5eec7a96ebba6796dda6221b83e91f14ebf7d diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 3d8642d66b..2081d8161f 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -69,7 +69,7 @@ Compute time, wall time, worker heap, cancellation, and fresh-worker isolation r Background producers return a typed canonical handle such as `{ kind: 'background', taskId }` while retaining their established Native sentence. A pre-aborted background call remains a failure because successful output promises an id and no task was created. After `ctx.tasks.start()` publishes the id, task-owned cancellation governs the work: settlement or later cancellation of the enclosing `run_code` call does not kill it. A later program can pass the returned id to `task_output`, and `task_kill`, owner disposal, or service teardown owns cancellation. Foreground execution remains coupled to the call signal. The task lifetime contract is owned by the [background task runtime note](../architecture/2026-06-20-generic-long-running-tool-runtime.md). -Dynamic Cordis mounting follows the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence. +Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, pluginName, state, provides, waitingFor }`, so a program can read `mounted.id`, inspect active or pending state, and pass that id to `cordis_unmount` without parsing the stable Native sentence. ### Persistence, metadata, and spill diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index fea1be3e23..0fa5eec7a9 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -69,7 +69,7 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper 后台 producer 返回类型化的规范句柄,例如 `{ kind: 'background', taskId }`,同时保留既有的 Native 语句。已预先中止的后台调用仍是失败,因为成功输出承诺返回 id,而此时并未创建任务。`ctx.tasks.start()` 发布 id 后,工作由任务自有的取消机制控制:外围 `run_code` 调用完成,或随后被取消,都不会终止该任务。后续程序可以把返回的 id 传给 `task_output`;取消则由 `task_kill`、owner dispose 或服务 teardown 负责。前台执行仍与本次调用的信号耦合。任务生命周期契约由[后台任务运行时 Agent Note](../architecture/2026-06-20-generic-long-running-tool-runtime.md)定义。 -动态 Cordis 挂载遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 +临时 Cordis Plugin 遵循同一规则:`cordis_mount` 返回 `{ id, pluginName, state, provides, waitingFor }`,因此程序可以直接读取 `mounted.id`,检查 active 或 pending 状态,并把该 id 传给 `cordis_unmount`,无需解析稳定的 Native 语句。 ### 持久化、元数据与输出落盘 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b1f73ed15a..bb2d360bac 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -1,6 +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-21-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69 -2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md +2026-07-21-cross-session-references.md: 18dab5fb85f2201258e2f15c3c069d2668ae80d3 +2026-07-21-cross-session-references.zh.md: 44e33ab1ed762b08b6be30d66b82396a5b519c02 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index fc084b36e7..18dab5fb85 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -10,7 +10,7 @@ TUI users need to bring relevant work from another conversation into one new mes ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]`, call `prepare()` before enqueue, and pass the returned contexts through the generic `SendOptions.contexts` boundary. Core agent packages know only that one queued message may carry frozen `HookContext[]`; they do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional sourced `UserMessageData` snapshot; core agent packages do not parse session URIs or read another log. `dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. @@ -22,17 +22,17 @@ Preparation deduplicates in first-appearance order, rejects the target id, enfor Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. -One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags. The `## My request:` text is a routing cue rather than the trust boundary: referenced data may spell those words inside a JSON string, but it cannot forge the closing `` tag or escape the data region. The same serializer drives each source's independent byte accounting. The context declares `prompt-prefix` placement, so AgentLoop persists one `user/message` or `steering/message` containing the snapshot, `## My request:` delimiter, and effective direct prompt. Its model-hidden envelope retains the direct display content and source/retention metadata. Target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type or a separate user-role context message. +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message` or `steering/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -`send()` and `steer()` snapshot content, resolved source, and contexts together as one deeply frozen lossless-JSON inbox record. Synthetic `inject()` accepts source and model-hidden metadata but not attached contexts, which belong to inbox messages. A claimed ordinary message exposes its attached contexts as the default `agent/prompt-submit` additional contexts; a block writes neither user message nor contexts. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream content and contexts unless it intentionally replaces them. After admission, absent or `separate` placement writes an independent `context/message`, while `prompt-prefix` placement bakes context and the effective request into one prompt event. Drained steering bypasses `agent/prompt-submit` but applies the same placement split. Late steering retains the same record when converted to queued input, while cancellation, disposal, and terminal discard drop message and contexts together. `agent/queued` reports the frozen contexts so the observation event describes the complete retained item. +TUI owns the snapshot/direct-message transaction without extending the generic inbox record. Outside the next-step acceptance window, it installs a one-shot outer `agent/prompt-submit` listener before `followup()`; an allowed decision receives the snapshot as `additionalContexts`, while a blocked or discarded prompt releases the listener and writes neither message. During prompt admission or an open turn, TUI calls `inject(snapshot)` then `steer(prompt)`, and AgentLoop stages both for the same safe boundary. If admission fails before that boundary, both remain staged for retry or a later admitted prompt; cancellation or disposal may discard them. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. -This preserves host driving semantics: TUI decides `send()` versus `steer()` from the agent state after preparation, so only its queued path dispatches UserPromptSubmit hooks. Reference preparation is not a new steering protocol and does not create a turn by itself. +Reference preparation is not a new steering protocol and does not create a turn by itself. A `followup()` outside the next-step acceptance window dispatches prompt admission; steering inside the window bypasses it while retaining snapshot order through the shared outbox. ## Host adapters -TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. +TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -45,15 +45,15 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Place a separate user-role context message beside the prompt** — rejected because two adjacent user messages weaken the prompt's deictic binding: in `@foo what does this session discuss?`, the model may resolve “this session” as the current conversation instead of the referenced snapshot. -- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. The effective prompt and attached contexts meet only after admission in AgentLoop, which can apply an `allow.content` rewrite consistently to both combined model content and `envelope.displayContent`; earlier host assembly would expose snapshot bytes to the hook or let those two views diverge. +- **Attach context to `SendOptions` and the inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step outbox preserve the required pairing without enlarging every message. +- **Bake the prefix host-side before `send()`** — rejected because `agent/prompt-submit` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. - **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fe4a876b52..44e33ab1ed 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -10,7 +10,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,在入队前调用 `prepare()`,再通过通用的 `SendOptions.contexts` 边界传递返回的上下文。核心 agent 包只知道一条排队消息可以携带已冻结的 `HookContext[]`;它们既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带来源信息的 `UserMessageData` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 @@ -22,17 +22,17 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。`## My request:` 文本只是路由提示,不是信任边界:被引用数据可以在 JSON 字符串中包含这些词,但无法伪造闭合的 `` 标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。该上下文声明 `prompt-prefix` 放置方式,因此 AgentLoop 会持久化一条 `user/message` 或 `steering/message`,其中包含快照、`## My request:` 分隔符和最终生效的直接提示词。其模型不可见封套保留直接显示内容以及来源与保留元数据。因此,目标回放无需新增事件类型或单独的用户角色上下文消息,也能满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 或 `steering/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -`send()` 与 `steer()` 会把内容、解析后的来源和上下文一起快照为一条深度冻结、无损 JSON 的收件箱记录。合成的 `inject()` 接受来源和模型不可见的元数据,但不接受附加上下文,因为上下文属于收件箱消息。普通消息被认领后,其附带的上下文会作为 `agent/prompt-submit` 的默认附加上下文公开;提示词被阻止时,系统既不写入用户消息,也不写入上下文。waterfall(瀑布式事件)返回的 allow 结果具有最终权威性,因此监听器包装 `next()` 时会保留下游内容与上下文,除非它有意替换这些值。消息被接纳后,未指定放置方式或指定为 `separate` 时会写入独立的 `context/message`;指定为 `prompt-prefix` 时则会把上下文与最终生效的请求合并写入同一个提示词事件。排空 steering 消息时会绕过 `agent/prompt-submit`,但采用相同的放置方式分流。延迟到达的 steering 转换为排队输入时保留同一条记录;取消、dispose(资源释放)和到达终止态后的丢弃则会同时丢弃消息与上下文。`agent/queued` 会报告已冻结的上下文,使观察事件能够描述完整的保留项。 +TUI 负责快照/直接消息事务,不扩展通用收件箱记录。在 next-step 接受窗口之外,它会在调用 `followup()` 前安装一次性的外层 `agent/prompt-submit` 监听器;获准决策会把快照作为 `additionalContexts` 接收,而被阻止或丢弃的提示词会释放监听器,并且不写入任何消息。提示词准入期间或轮次打开时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,AgentLoop 则将两者暂存到同一个安全边界。如果准入在抵达该边界前失败,两者都会保留暂存状态,供重试或后续获准提示词使用;取消或资源释放可能丢弃它们。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 -这保留了宿主的驱动语义:TUI 在准备完成后根据 agent 状态决定调用 `send()` 还是 `steer()`,因此只有它的排队路径才会分派 UserPromptSubmit 钩子。引用准备过程不是新的 steering 协议,本身也不会创建轮次。 +引用准备过程不是新的 steering 协议,本身也不会创建轮次。在 next-step 接受窗口之外调用 `followup()` 会分派提示词准入;窗口内的 steering 会绕过它,同时通过共享 outbox 保持快照顺序。 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -45,15 +45,15 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **在提示词旁放置单独的用户角色上下文消息**:不予采纳,因为相邻的两条用户消息会削弱提示词的指示语绑定:在 `@foo what does this session discuss?` 中,模型可能把「this session」解析为当前对话,而不是被引用的快照。 -- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。最终生效的提示词与附加上下文只有在 AgentLoop 接纳后才汇合;此时 AgentLoop 可以把 `allow.content` 改写一致应用于合并后的模型内容和 `envelope.displayContent`。若由宿主更早组装,就会向该钩子暴露快照字节,或使这两个视图发生偏离。 +- **把上下文附加到 `SendOptions` 和收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step outbox 可以保持所需配对,而无需扩大每条消息。 +- **在调用 `send()` 前由宿主合并前缀**:不予采纳,因为 `agent/prompt-submit` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 - **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 40dd8f463e..6d9112d663 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-skill-slash-command.md: d7532a05fce5605491ce42c87a2a523eb4c19acc -2026-07-21-tui-skill-slash-command.zh.md: 16930020bd404f7bc9476169cd1d063aa57b5c94 +2026-07-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 +2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index d7532a05fc..8370ab61f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -14,7 +14,7 @@ The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door o The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 16930020bd..66edec6ecd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -14,7 +14,7 @@ Status: implemented TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml new file mode 100644 index 0000000000..cd9e4f7e9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.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 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17 +2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md new file mode 100644 index 0000000000..a58598d8a9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -0,0 +1,37 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md) + +## Problem + +Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker. + +## Decision + +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: + +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. + +The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. + +## Alternatives considered + +**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. + +**No in-process redaction point, delegating to receiver-side collector processors.** Rejected — receiver-side redaction ships the secret first and scrubs it second. The waterfall puts an auditable, stackable scrubbing point before bytes leave the process; where the branch version (what PR #222 shipped) had no redaction point at all, every record now passes one. + +**A built-in conservative rule set as the waterfall's innermost `next()`.** Rejected: as an SDK we cannot know which patterns are secrets in a given deployment, a shipped list invites false confidence ("redaction is on") while catching only known shapes, and false positives would corrupt exported bodies for consumers who never asked. The seam owns the mechanism; the deployment owns the policy — the innermost `next()` is a pass-through, and rules mount as listeners. + +**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. + +**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. The boundary also swallows the synthetic turn closers `SessionPersistence.load()` writes when repairing a crash-interrupted log (they sit below `firstLiveSeq` despite never existing in the previous process) — deliberate, not incidental: exporting a synthetic closer cannot complete the remote turn whose real tail records died in the crashed process's queue, it can only make an incomplete turn look closed. The wire stream stays faithful to what the crashed process actually shipped; receivers read a never-closed turn on a resumed stream as "the previous process died inside it" (the OTel README states the rule), and a later clean `shutdown` marker attests only to the resumed process's exit. Threading the pre-repair boundary through load/prepare so repairs export as live events would couple three packages to un-ship that signal. + +**Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. + +## Consequences + +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md new file mode 100644 index 0000000000..cc09717e34 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +[English](2026-07-23-session-telemetry-otel-revival.md) | 中文 + +## Problem + +每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。 + +## Decision + +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: + +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 + +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 + +## Alternatives considered + +**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 + +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 + +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 + +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 + +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 + +**将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 + +## Consequences + +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.i18n.yaml new file mode 100644 index 0000000000..0573cca056 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.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-23-tui-footer-session-identity.md: aa17ead4194c52464de0caad86d8611eae94786c +2026-07-23-tui-footer-session-identity.zh.md: 686c11294ffd02304dc87cd790252a347fe35011 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md new file mode 100644 index 0000000000..aa17ead419 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.md @@ -0,0 +1,27 @@ +# Agent Note: Keep the TUI session identity visible + +Status: implemented + +English | [中文](2026-07-23-tui-footer-session-identity.zh.md) + +## Problem + +The startup banner identifies the active session, but it scrolls out of view during a conversation. Operators working with several resumable sessions then lack a persistent way to confirm which session receives their input. + +## Decision + +The TUI footer begins with the active session id, before the model, working directory, token counts, cache rate, and context use. It shows tool-card state only while cards are expanded; the default collapsed state adds no label. The session id uses the same control-character escaping as other terminal labels and participates in the footer's existing left-to-right clipping behavior. + +The footer reads the id from the mounted agent's session, so fresh and resumed sessions use the same authoritative identity without separate UI state. + +## Alternatives considered + +- **Keep the identity only in the startup banner** — rejected because the banner leaves the viewport in longer conversations. +- **Show the session id only in `/status`** — rejected because an on-demand diagnostic does not let an operator confirm identity before sending input. +- **Put the session id in the right footer segment** — rejected because narrow terminals clip that segment first; session identity is more important than context and expanded tool-card state. + +## Consequences + +The current session remains identifiable while the editor is active. On narrow terminals, the longer left segment leaves less room for context and the expanded tool-card label, while the existing clipping policy preserves session identity, model, and as much operational context as fits. + +Package coverage pins the footer ordering and escaping path, and the runnable TUI terminal snapshots pin the assembled layout. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md new file mode 100644 index 0000000000..686c11294f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-footer-session-identity.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 保持 TUI 会话标识可见 + +[English](2026-07-23-tui-footer-session-identity.md) | 中文 + +Status: implemented + +## Problem + +启动横幅会标识当前会话,但在对话过程中会滚出视野。操作多个可恢复会话时,用户因而无法持续确认输入将发送到哪个会话。 + +## Decision + +TUI 页脚以当前会话 id 开头,之后依次显示模型、工作目录、token 用量、缓存命中率和上下文用量。工具卡片状态仅在卡片展开时显示;默认的折叠状态不添加任何标签。会话 id 与其他终端标签采用相同的控制字符转义,并遵循页脚现有的从左到右裁剪行为。 + +页脚从已挂载 agent 的会话读取 id,因此新建和恢复的会话都使用同一权威标识,无需单独维护 UI 状态。 + +## Alternatives considered + +- **仅在启动横幅中保留标识** — 未采用,因为对话较长时横幅会离开视野。 +- **仅在 `/status` 中显示会话 id** — 未采用,因为按需诊断无法让用户在发送输入前确认会话标识。 +- **将会话 id 放入页脚右侧区域** — 未采用,因为窄终端会优先裁剪该区域;会话标识比上下文和展开的工具卡片状态更重要。 + +## Consequences + +编辑器处于活动状态时,当前会话始终可识别。在窄终端中,更长的左侧区域会减少上下文和展开的工具卡片标签的显示空间;现有裁剪策略会保留会话标识、模型,以及空间允许的其他运行信息。 + +包级覆盖固定页脚顺序和转义路径,可运行 TUI 的终端快照固定组装后的布局。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml new file mode 100644 index 0000000000..177ddc27e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-tui-status-prompt-tools.md: 42524d021d0f2786371762b447ad5d195dc828bd +2026-07-23-tui-status-prompt-tools.zh.md: 5a33e19e9780749a721395a0b07f43790103013c diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md new file mode 100644 index 0000000000..42524d021d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.md @@ -0,0 +1,29 @@ +# Agent Note: TUI status inspects model request inputs + +Status: implemented + +English | [中文](2026-07-23-tui-status-prompt-tools.zh.md) + +## Problem + +Session counters describe activity but do not reveal the instructions and capabilities that the next model request receives. Diagnosing scoped prompt contributions and tool restrictions otherwise requires leaving the TUI or inferring configuration from files. + +## Decision + +`/status` assembles the current agent's system prompt through `ctx.systemPrompt` and renders it with the same renderer used by the agent loop. After the bordered diagnostics card, separate unbordered `System prompt` and `Registered tools` sections show the rendered prompt and the assembly's ordered tool names, which are the schemas exposed to the model for that agent and presentation mode. + +Assembly uses the command's cancellation signal and current agent scope, so scoped sections, variables, tool restrictions, and assembly listeners match a request made at that point. Prompt and tool values are escaped through the TUI's terminal-control sanitizer before rendering. Empty prompt text and an empty tool list render as `(empty)` and `(none)`. + +## Alternatives considered + +**Read prompt sections and the tool registry independently.** Rejected: that bypasses prompt assembly waterfalls, tool ordering, presentation modes, and per-agent restrictions, so the diagnostics could disagree with the next request. + +**Show complete tool schemas.** Rejected: names answer which capabilities are registered without making the status card dominated by parameter JSON; schema details remain available in the generated tool catalog and source definitions. + +## Consequences + +The command can run prompt providers and assembly listeners, just like request preparation, and reports their failures through the existing command-error notice. The snapshot is point-in-time: a later registration, restriction, mode change, or dynamic provider can alter the next request. + +## Testing + +Unit coverage pins scoped assembly output, ordered tool names, empty labels, and terminal-control escaping. The keyless TUI smoke and terminal snapshot exercise `/status` through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md new file mode 100644 index 0000000000..5a33e19e97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-tui-status-prompt-tools.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 状态检查模型请求输入 + +Status: implemented + +[English](2026-07-23-tui-status-prompt-tools.md) | 中文 + +## 问题 + +会话计数器可以描述活动情况,却无法显示下一次模型请求将收到的指令和能力。若要诊断按作用域贡献的提示词与工具限制,用户只能离开 TUI,或根据配置文件进行推断。 + +## 决策 + +`/status` 通过 `ctx.systemPrompt` 为当前 agent(智能体)组装系统提示词,并使用与 agent loop(智能体循环)相同的渲染器完成渲染。在带边框的诊断卡片之后,独立且无边框的 `System prompt` 和 `Registered tools` 区域分别显示渲染后的提示词与 assembly 中按顺序排列的工具名称;这些名称对应当前 agent 与呈现模式向模型公开的 schema。 + +组装使用命令的取消信号和当前 agent 作用域,因此按作用域注册的 section、变量、工具限制及 assembly listener 与此时发起的请求保持一致。提示词和工具值在呈现前经过 TUI 的终端控制字符净化。空提示词与空工具列表分别显示为 `(empty)` 和 `(none)`。 + +## 曾考虑的替代方案 + +**分别读取提示词 section 和工具注册表。** 已否决:该做法会绕过提示词组装 waterfall(瀑布式事件)、工具排序、呈现模式和按 agent 限制,因此诊断结果可能与下一次请求不一致。 + +**显示完整工具 schema。** 已否决:工具名称足以回答注册了哪些能力,同时避免参数 JSON 占据大部分状态卡片;schema 详情仍可在生成的工具目录和源代码定义中查看。 + +## 后果 + +该命令可能像请求准备一样运行提示词提供方与 assembly listener,并通过现有命令错误提示报告失败。结果是一个时点快照:后续注册、限制、模式变更或动态提供方都可能改变下一次请求。 + +## 测试 + +单元测试固定按作用域组装的输出、工具名称顺序、空值标签和终端控制字符转义。无密钥 TUI 冒烟测试与终端快照通过完整组装的应用执行 `/status`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml new file mode 100644 index 0000000000..4532ab2148 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.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-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 +2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md new file mode 100644 index 0000000000..4008f23a3f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -0,0 +1,39 @@ +# Agent Note: TUI prompt themes compose mutable plugin values + +Status: implemented + +English | [中文](2026-07-24-configurable-tui-prompt-theme.zh.md) + +## Problem + +The terminal prompt row and editor prefix were assembled inside the TUI from a fixed set of workspace, model, usage, cache, context, and timing fields. Deployments could change colors globally but could not choose field order, replace the input prefix, add plugin state, or build a Powerline prompt. + +## Decision + +The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPrompt`, and the static running-state `inputPlaceholder`. The three prompt strings interpolate `${name}` references; unknown or unavailable values disappear with adjacent horizontal separator whitespace. The left and right templates share one row, retain the right side on overlap, and use ANSI-aware visible widths. The input template controls the first-line editor prefix and continuation indentation. + +`ctx.tuiPrompt` is a context-global registry supplied by `@deepseek-ai/dsh-tui/prompt`. `register(name, initialValue)` returns a handle with `set(value)` and `dispose()`. Values are stored strings rather than callbacks: updates are explicit, unchanged strings are ignored, and a registration, mutation, or disposal schedules one coalesced notification. The renderer reads current values with `get(name)` and subscribes with `subscribe(listener)` to learn when to redraw. That subscription is a direct in-service callback, not a Cordis event, so a value changing on its own schedule still repaints without a bus entry other consumers would never use. Both `subscribe` and each registration are owned by the caller's Cordis effect, so they are removed when the subscriber's or contributor's fiber disposes. Each `subscribe` call is a distinct subscription keyed by record identity, so two fibers may pass the same callback and disposing one leaves the other live. The coalesced notification contains every observer — a synchronous throw, a rejected returned promise, and even an error hostile to string rendering (logs go through the non-throwing `errorChain`) — so one broken observer cannot starve the rest, and it re-checks each subscription's liveness during delivery so a listener that synchronously unsubscribes another in the same burst silences it immediately. Registration follows Cordis effect ownership, rejects duplicate names, and removes the value on plugin disposal. + +Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers. + +The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. + +## Alternatives considered + +**Evaluate synchronous provider callbacks on every render.** Rejected: render-time plugin code adds an avoidable failure boundary; stored strings keep the render pass free of plugin evaluation. + +**Publish the change notification as a Cordis event.** Rejected: the notification has exactly one consumer (the TUI renderer for the current session), so a global typed event adds a bus entry, scoped-dispatch surface, and cross-plugin fan-out no one else observes. A direct `subscribe` callback contained inside the service carries the same coalesced redraw with less surface. + +**Expose semantic style roles instead of ANSI.** Rejected: semantic roles cannot express arbitrary Powerline background transitions without expanding the shared style protocol for each presentation technique. + +**Put prompt fields at the top level of TUI config.** Rejected: templates and color selection jointly define terminal presentation and belong under one `theme` object. + +## Consequences + +Prompt contributors depend on the TUI-specific registry and are loaded after the service but before the TUI consumer. The namespace is global to the Cordis context, matching the TUI's current single-session transcript ownership. Arbitrary ANSI is intentionally trusted: unsupported cursor-affecting sequences can disrupt layout, and alignment is reliable only for sequences understood by pi-tui's visible-width utilities. + +Changing `inputPrompt` through a registered value preserves editor text, cursor, history, completion, and focus because pi-tui supports replacing equal-width first and continuation prefixes in place. The static `inputPlaceholder` is sanitized and appears only while the agent runs and the editor is empty. + +## Testing + +Registry tests pin validation, duplicate rejection, updates, unavailable values, coalesced-notification containment, unsubscribe, disposal, interpolation, trailing-literal retention, whitespace cleanup, and ANSI preservation. TUI tests pin nested theme defaults, custom templates, out-of-band value redraw, mutable redraw, Powerline-capable fragments, dynamic input-prefix width, and the static running placeholder. The assembled TUI demo test pins service load order and config forwarding. diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md new file mode 100644 index 0000000000..daf15b54d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -0,0 +1,39 @@ +# Agent Note: TUI 提示符主题组合可变的插件值 + +Status: implemented + +[English](2026-07-24-configurable-tui-prompt-theme.md) | 中文 + +## 问题 + +终端提示符行与编辑器前缀原先在 TUI 内部由一组固定字段拼装而成,涵盖工作区、模型、用量、缓存、上下文与计时。部署方可以全局更改颜色,却无法调整字段顺序、替换输入前缀、加入插件状态,也无法构建 Powerline 风格的提示符。 + +## 决策 + +TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPrompt` 以及运行状态下的静态 `inputPlaceholder` 归为一组。三个提示符字符串通过插值引用 `${name}`;未知或不可用的值连同相邻的横向分隔空白一起消失。左右模板共用一行,重叠时保留右侧,宽度计算使用可识别 ANSI 的可见宽度。输入模板控制编辑器首行前缀与续行缩进。 + +`ctx.tuiPrompt` 是由 `@deepseek-ai/dsh-tui/prompt` 提供的上下文全局注册表。`register(name, initialValue)` 返回带 `set(value)` 与 `dispose()` 的句柄。存储的值是字符串而非回调:更新必须显式发起,未变化的字符串会被忽略,而一次注册、变更或 dispose 会安排一次合并后的通知。渲染器用 `get(name)` 读取当前值,并用 `subscribe(listener)` 订阅何时重绘。该订阅是服务内部的直接回调,而非 Cordis 事件,因此一个自行变化的值仍能重绘,而不需要一个其他消费方永远不会观察的总线条目。`subscribe` 与每个注册都由调用方的 Cordis effect 拥有,因此在订阅方或贡献方的 fiber dispose 时一并移除。每次 `subscribe` 都是一个按记录身份区分的独立订阅,因此两个 fiber 可以传入同一个回调,而 dispose 其中一个不会影响另一个。合并通知会容错每个观察者——同步抛出、返回被拒 promise,甚至一个对字符串渲染也会抛异常的错误(日志走不抛异常的 `errorChain`)——因此一个损坏的观察者不会饿死其余观察者;并且在派发过程中会重新校验每个订阅的存活性,因此同一批次中同步取消了另一个订阅的监听器会立即使其静默。注册遵循 Cordis 的 effect 所有权模型,拒绝重复名称,并在插件 dispose(资源释放)时移除对应的值。 + +注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 + +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 + +## 曾考虑的替代方案 + +**每次渲染时求值同步的提供方回调。** 不予采纳:在渲染期执行插件代码会引入一个本可避免的故障边界;存储字符串能让渲染过程不涉及插件求值。 + +**把变更通知发布为 Cordis 事件。** 已否决:该通知只有一个消费方(当前会话的 TUI 渲染器),因此全局类型事件会增加一个总线条目、scope 分发面以及无人观察的跨插件扇出。服务内部包裹的直接 `subscribe` 回调以更小的面积承载同样的合并重绘。 + +**暴露语义化的样式角色而非 ANSI。** 不予采纳:语义角色无法表达任意的 Powerline 背景色过渡,除非为每种呈现技巧扩展共享的样式协议。 + +**把提示符字段放在 TUI 配置顶层。** 不予采纳:模板与颜色选择共同定义终端呈现,应归属于同一个 `theme` 对象之下。 + +## 后果 + +提示符值的贡献插件依赖 TUI 专属的注册表,加载顺序位于该服务之后、TUI 消费方之前。命名空间对整个 Cordis 上下文全局生效,与 TUI 当前的单会话 transcript(文本记录)所有权一致。允许任意 ANSI 是有意的信任决策:不受支持的、影响光标的序列可能破坏布局,只有 pi-tui 可见宽度工具能理解的序列才能保证对齐可靠。 + +通过注册值更改 `inputPrompt` 时,编辑器文本、光标、历史、自动补全与焦点均得以保留,因为 pi-tui 支持原地替换等宽的首行与续行前缀。静态的 `inputPlaceholder` 会被清洗,且仅在 agent 运行且编辑器为空时显示。 + +## 测试 + +注册表测试固定校验、重名拒绝、更新、不可用值、合并通知的容错、取消订阅、dispose、插值、尾随字面保留、空白清理与 ANSI 保留等行为。TUI 测试固定嵌套主题默认值、自定义模板、带外值重绘、可变重绘、支持 Powerline 的片段、动态输入前缀宽度以及运行状态下的静态占位文本。组装后的 TUI 演示测试固定服务加载顺序与配置转发。 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml new file mode 100644 index 0000000000..7e865d0d6d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.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-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c +2026-07-24-provider-retry-policies.zh.md: 788f1f1963861e1b46ff0d8798e53e01bd9892b4 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md new file mode 100644 index 0000000000..1831ce6b96 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -0,0 +1,65 @@ +# Agent Note: Per-provider request retry policies + +Status: implemented + +English | [中文](2026-07-24-provider-retry-policies.zh.md) + +## Problem + +One process may route model requests to providers with different reliability and cost constraints. A single transient classifier and finite retry budget cannot express a deployment that wants bounded recovery for most providers but requires one provider to keep retrying every model-request failure until the request succeeds or the caller cancels it. + +Provider policy must follow the request that actually failed, including a route selected by `agent/request`, rather than the agent's initial options. Unbounded policy also cannot store JavaScript `Infinity` in the durable session event, and neither provider error text nor discarded partial output may enter the next model request. + +## Decision + +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without `retryPolicy` uses the normal defaults. + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +The listener reads the provider from the durable `request/header` in force when the failed step closed, excluding later recovery mutations, but never re-resolves policy from the mutable provider registry. It derives a canonical key from every field of the resolved serving policy, sorting `retryableCodes` because eligibility uses set membership, and continues retry history only for the same provider and key. Replacing a route with different limits, code membership, or backoff therefore starts a new count and initial delay even when the mode is unchanged. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries` and otherwise delegates. + +Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. The retry listener owns and drains delegated recovery before cancellation or plugin disposal can finish, then applies the abort instead of a late downstream decision. Success, turn cancellation, and plugin disposal are the only termination paths. + +Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff. + +Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface. + +## Alternatives considered + +**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. + +**A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. + +**A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. + +**Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record. + +**Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request. + +## Verification + +Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits. + +## Consequences + +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract. + +This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md new file mode 100644 index 0000000000..788f1f1963 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 逐提供方请求重试策略 + +Status: implemented + +[English](2026-07-24-provider-retry-policies.md) | 中文 + +## 问题 + +同一进程可能把模型请求路由到可靠性和成本约束各不相同的提供方。单一的瞬态错误分类器与有限重试预算无法表达这种部署需求:大多数提供方只需有界恢复,但其中一个提供方必须持续重试每次模型请求失败,直到请求成功或调用方取消。 + +提供方策略必须跟随实际失败的请求,包括 `agent/request` 选择的路由,而不能跟随 agent(智能体)的初始选项。无界策略也不能把 JavaScript `Infinity` 存入持久会话事件;提供方错误文本与丢弃的部分输出都不得进入下一次模型请求。 + +## 决策 + +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose 或替换,agent loop 仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。 + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +监听器从失败步骤关闭时生效的持久 `request/header` 读取提供方,后续恢复产生的改动不参与选择,但绝不会从可变的提供方注册表重新解析策略。它会根据已解析实际服务策略的所有字段生成规范键;由于错误资格按集合成员判断,生成时会对 `retryableCodes` 排序。重试历史只会对同一提供方和同一规范键延续。因此,即使模式未变,只要路由替换后的次数上限、错误代码成员或退避不同,重试计数与初始延迟都会重新开始。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;其他情况委托后续处理。 + +always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。重试监听器会持有并排空已委托的恢复,轮次取消或插件 dispose(资源释放)只能在其结束后完成;随后监听器会应用取消,而不会采用迟到的下游决定。成功、轮次取消和插件 dispose 是仅有的终止路径。 + +两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 + +每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、已解析策略的规范键、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。 + +## 曾考虑的替代方案 + +**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 + +**在 `dsh-llm-retry` 上维护单独的精确提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 + +**设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的契约,并把任意选取的运维上限序列化成看似有意义的数值。 + +**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭 step 的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 + +**把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。 + +## 验证 + +适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。 + +## 后果 + +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复契约。 + +本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭 step 恢复、单次可见适配器尝试、结构化失败与持久状态设计。 diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.i18n.yaml new file mode 100644 index 0000000000..88fbc26ceb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.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-24-readable-xml-tool-output.md: 4f7327a7c6f5e2f04e36576da0fb739c34955e8a +2026-07-24-readable-xml-tool-output.zh.md: 3c56d256b489863210b44449111f03a5752a889a diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md new file mode 100644 index 0000000000..4f7327a7c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.md @@ -0,0 +1,27 @@ +# Agent Note: Readable XML tool output + +Status: implemented + +English | [中文](2026-07-24-readable-xml-tool-output.zh.md) + +## Problem + +Model-facing context and tool result text can expose transport-oriented XML wrappers instead of the information people need. Context producers do not declare presentation intent, and replayed tool calls whose definition is unavailable still need a conservative fallback that does not reinterpret ordinary prose or partial markup. + +## Decision + +The read tool declares a generic completed-result presentation that removes its ``, ``, and `` wrapper while preserving the numbered content and footer. This tool-owned projection applies consistently to every UI that consumes tool presentation intent. + +The TUI parses a context message or unavailable-tool result as XML only when the complete text is one supported XML document. It renders element names and attributes as an indented tree, preserves the context source label, applies the collapsed line budget independently to each tool result's top-level child lines and child count, and keeps raw text for malformed XML, mixed text, declarations, processing instructions, doctypes, and comments. A known tool's raw XML remains literal unless that tool declares its own result presenter. This XML fallback is TUI-only. + +## Alternatives considered + +**Strip XML-like tags with regular expressions.** Rejected because nested elements, attributes, entities, and malformed input require a real parser; partial conversion would make ambiguous output harder to inspect. + +**Parse every generic result.** Rejected because known tools own their presentation contract, and silently reinterpreting their literal XML would override that decision. + +**Show only raw XML.** Rejected because wrappers optimized for model consumption add terminal noise, particularly for filesystem reads and deeply nested structured results. + +## Consequences + +Filesystem reads are shorter in TUI cards without changing canonical model-facing content. Complete XML context messages, including workspace instruction reminders, become readable trees; unknown complete XML results become navigable trees and retain per-child context when collapsed. The TUI adds a strict SAX parser dependency and deliberately declines XML features (undefined entities, DOCTYPE, comments, processing instructions) that could hide or transform input beyond the conservative tree view. Predefined entities and character references do expand, so parsed text and attribute values are re-escaped for terminal output after parsing: a character reference can produce a control character that escaping the raw source never saw. Other UIs show raw generic content. diff --git a/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md new file mode 100644 index 0000000000..3c56d256b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-readable-xml-tool-output.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 可读的 XML 工具输出 + +Status: implemented + +[English](2026-07-24-readable-xml-tool-output.md) | 中文 + +## 问题 + +面向模型的上下文和工具结果文本可能呈现面向传输的 XML 包装,而不是人们真正需要的信息。上下文生产方不声明呈现意图,而对于回放时拿不到工具定义的调用,仍需要一个保守的回退方案,并且该方案不得重新解释普通文字或不完整的标记。 + +## 决策 + +read 工具声明一个通用的完成结果呈现:去除自身的 ``、`` 和 `` 包装,同时保留带行号的内容和尾部信息。这一由工具自身持有的投影一致地作用于所有消费工具呈现意图的 UI。 + +只有当完整文本恰为一个受支持的 XML 文档时,TUI 才把上下文消息或工具定义不可用的工具结果按 XML 解析。TUI 将元素名和属性渲染为缩进树,保留上下文的来源标签;对于每个工具结果,分别按折叠行数预算限制各顶层子元素的行数和顶层子元素数量;对于格式错误的 XML、混合文本、XML 声明、处理指令、doctype 和注释,则保留原始文本。除非已知工具声明了自己的结果呈现器,否则其原始 XML 仍按字面显示。这一 XML 回退机制仅限 TUI。 + +## 曾考虑的替代方案 + +**用正则表达式剥除类 XML 标签。** 已否决:嵌套元素、属性、实体和格式错误的输入都需要真正的解析器;部分转换会让本就有歧义的输出更难检查。 + +**解析所有通用结果。** 已否决:已知工具拥有自己的呈现契约,静默重新解释它们的字面 XML 会推翻这一决定。 + +**只显示原始 XML。** 已否决:为模型消费而优化的包装会给终端增加噪音,对文件系统读取和嵌套很深的结构化结果尤其如此。 + +## 后果 + +文件系统读取在 TUI 卡片中变得更短,而规范的面向模型内容保持不变。完整的 XML 上下文消息(包括工作区指令提醒)变成可读的树;未知的完整 XML 结果变成可导航的树,折叠时也保留每个子元素的上下文。TUI 新增一个严格 SAX 解析器依赖,并有意不支持那些可能在保守树视图之外隐藏或变换输入的 XML 特性(未定义实体、DOCTYPE、注释、处理指令)。预定义实体和字符引用会被展开,因此解析出的文本和属性值在解析后会为终端输出重新转义:字符引用可能产生对原始源文本转义时从未见过的控制字符。其他 UI 展示原始的通用内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.i18n.yaml new file mode 100644 index 0000000000..8cb52df982 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.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 .agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md +2026-07-24-tui-banner-model-deduplication.md: afd370a8762d8e6c17c61d50c95d68998a063df5 +2026-07-24-tui-banner-model-deduplication.zh.md: 86a3cdb0e714642253162f1fe062e19bdc40bbe4 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md new file mode 100644 index 0000000000..afd370a876 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.md @@ -0,0 +1,33 @@ +# Agent Note: The startup banner omits the model + +Status: implemented + +English | [中文](2026-07-24-tui-banner-model-deduplication.zh.md) + +## Problem + +The startup banner repeated the selected model directly above the prompt context, which already keeps the model visible while the TUI is idle. The duplicate added no information and made the banner detail line harder to scan. + +## Decision + +- The borderless startup banner shows the product title, optional `welcome` or session-title subtitle, and session id. +- The banner omits the model name. The prompt context remains the persistent model display and updates after `/model` selection. +- The sweep animation and configured-welcome behavior are unchanged. + +This supersedes only the model-in-banner portion of the [borderless banner decision](../../archived/feature/2026-07-21-tui-borderless-banner.md). + +## Alternatives considered + +**Remove the entire detail line.** Rejected: the session id remains useful for identifying and resuming the active session, and it is not duplicated in the prompt context. + +**Remove the model from the prompt context instead.** Rejected: the prompt context stays visible after the startup banner scrolls away and reflects later model selections. + +## Consequences + +- Startup uses the banner detail row only for the session id. +- The model appears once in the initial idle view, in the prompt context. +- Banner snapshots and runnable TUI replay snapshots contain a shorter detail row. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` asserts that completed banners retain the session id without the former `` text. Package-local and runnable-example TUI snapshots pin the resulting rows. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md new file mode 100644 index 0000000000..86a3cdb0e7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-banner-model-deduplication.zh.md @@ -0,0 +1,33 @@ +# Agent Note:启动横幅不再显示模型 + +Status: implemented + +[English](2026-07-24-tui-banner-model-deduplication.md) | 中文 + +## 问题 + +启动横幅在提示区上下文(prompt context)的正上方重复显示所选模型,而提示区上下文本身已在 TUI 空闲时持续展示模型。这一重复不提供任何信息,还让横幅详情行更难扫读。 + +## 决策 + +- 无边框启动横幅显示产品标题、可选的 `welcome` 或会话标题副标题,以及会话 id。 +- 横幅不再显示模型名。提示区上下文仍是常驻的模型展示位,并在 `/model` 选择后随之更新。 +- 扫入动画和配置了欢迎语时的行为保持不变。 + +本 note 仅取代[无边框横幅决策](../../archived/feature/2026-07-21-tui-borderless-banner.md)中模型进横幅的那部分。 + +## 考虑过的替代方案 + +**移除整条详情行。** 否决:会话 id 对识别和恢复当前会话仍然有用,而且它在提示区上下文中没有重复。 + +**改为把模型从提示区上下文移除。** 否决:提示区上下文在启动横幅滚出视野后仍保持可见,并会反映之后的模型选择。 + +## 后果 + +- 启动时横幅详情行只承载会话 id。 +- 在初始空闲视图中模型只出现一次,位于提示区上下文。 +- 横幅快照和可运行的 TUI 回放快照包含更短的详情行。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 断言完成后的横幅保留会话 id,且不含先前的 `` 文本。包内快照与可运行示例的 TUI 快照固定了最终的各行内容。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.i18n.yaml new file mode 100644 index 0000000000..481dc5cad7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.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-24-tui-message-header-timing.md: 94a4d04c75f9b0ad76e2460738a07ba82ac3bb9f +2026-07-24-tui-message-header-timing.zh.md: 4713555290bbc47bb3af56cd3b4d0c493c81e6f1 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md new file mode 100644 index 0000000000..94a4d04c75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.md @@ -0,0 +1,25 @@ +# Agent Note: TUI message header timing + +Status: implemented + +English | [中文](2026-07-24-tui-message-header-timing.zh.md) + +## Problem + +Turn timing beside the editor disappears from the transcript when the user scrolls and cannot appear until the editor status renders. A whole-turn aggregate also obscures the latency of later model requests after tool calls. + +## Decision + +Every model step creates an assistant header at `step/start`, before the first streamed chunk. The header displays `Model wait` immediately and refreshes at 100 ms resolution, then adds exclusive `Thinking`, `Response`, and `Tools` buckets as session events move the step between phases. + +`step/end` freezes the header and adds the local completion timestamp. Transcript replay derives the same timing from durable event timestamps. Empty and tool-only steps retain a header, while failed live output and its header retract together when retry handling rebuilds the active session surface. + +The prompt context retains only queued-steering state. Timing belongs to the model step that produced it rather than to the editor or the whole turn. + +## Alternatives considered + +Keeping timing beside the editor preserves a stable layout but hides per-step latency in scrollback and resume. Adding a second status line duplicates the same metric in two places. Labeling the first bucket `TTFT` is compact but requires protocol terminology; `Model wait` states the user-visible meaning without claiming that the first chunk is always text. + +## Consequences + +Users receive visible feedback before model output and can compare each request after tools or retries. Updating at 100 ms resolution causes more terminal renders while a model step is active. Internal timing state keeps the established `ttft` name because it identifies the measured bucket precisely; only rendered text uses `Model wait`. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md new file mode 100644 index 0000000000..4713555290 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-message-header-timing.zh.md @@ -0,0 +1,25 @@ +# Agent Note:TUI 消息头部计时 + +Status: implemented + +[English](2026-07-24-tui-message-header-timing.md) | 中文 + +## 问题 + +编辑器旁的轮次计时会在用户滚动时从 transcript(文本记录)中消失,且要等到编辑器状态渲染后才能出现。整轮聚合值还会掩盖工具调用之后各后续模型请求的延迟。 + +## 决策 + +每个模型步骤都在 `step/start` 时(即第一个流式分片到达之前)创建一个 assistant 头部。头部立即显示 `Model wait` 并以 100 ms 分辨率刷新;随着会话事件使该步骤在不同阶段之间切换,头部再加入互斥的 `Thinking`、`Response` 和 `Tools` 时间桶。 + +`step/end` 冻结头部并附上本地完成时间戳。transcript 回放从持久事件时间戳派生出相同的计时。空步骤和纯工具步骤同样保留头部;当重试处理重建活跃会话表层时,失败的实时输出与其头部一并撤除。 + +提示区上下文(prompt context)只保留排队中的 steering(中途引导)状态。计时归属于产生它的模型步骤,而不是编辑器或整个轮次。 + +## 考虑过的替代方案 + +把计时留在编辑器旁能保持布局稳定,但在 scrollback 和会话恢复中看不到各步骤的延迟。增加第二条状态行会让同一指标出现在两处。把第一个时间桶标为 `TTFT` 更紧凑,但依赖协议术语;`Model wait` 直接陈述用户可见的含义,而不宣称第一个分片总是文本。 + +## 后果 + +用户在模型输出之前就能得到可见反馈,并能比较工具或重试之后的每次请求。以 100 ms 分辨率刷新会在模型步骤活跃期间带来更多终端渲染。内部计时状态沿用既有的 `ttft` 名称,因为它精确标识所计量的时间桶;只有渲染文本使用 `Model wait`。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.i18n.yaml new file mode 100644 index 0000000000..dff4800cf7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.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-24-tui-prompt-status-indicator.md: 8d469c3b0627325f373ca8f8d4d23d67bb09e342 +2026-07-24-tui-prompt-status-indicator.zh.md: 0dee8d1e4e7ce5a6f0299f9b7cd1595f63bd6e08 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md new file mode 100644 index 0000000000..8d469c3b06 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.md @@ -0,0 +1,33 @@ +# Agent Note: TUI prompt status indicator + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-status-indicator.zh.md) + +## Problem + +While a turn runs, the input prompt shows only its static `dsh>` prefix. The assistant header carries the elapsed timing, but the editor row — where the user's attention rests — gives no live signal of what the agent is doing right now: waiting for the first token, thinking, responding, or running tools. + +## Decision + +While the agent is running, a phase-specific glyph replaces the `>` caret of the built-in `${indicator}` prompt value. The `inputPrompt` theme template defaults to `${symbol} ${indicator}`, where the built-in `${symbol}` value holds the `dsh` label and `${indicator}` holds the caret slot with its trailing gap before the cursor; the template literal space separates them, rendering `dsh ` in every state. The phase is the open step's active timing bucket, derived from the same session events and rules that drive the [message header timing](2026-07-24-tui-message-header-timing.md) — no new phase model. One glyph per bucket: `◍` model wait (pre-first-token), `✻` thinking, `●` responding, `⚙` tools. A running turn with no open step falls back to the model-wait glyph; an idle agent restores the plain `>`. + +The glyph occupies the caret's exact column with the same display width every frame, so the cursor never shifts as the phase changes or the glyph animates. Activity is conveyed by a brightness pulse, not by appearing and disappearing: a four-frame triangle wave (dim → normal → bold → normal) wraps the accent-colored glyph in the true SGR intensity codes (2 and 1) — never the palette's semantic `dim` role, which on a light scheme is a color the glyph's own accent would override — so the pulse survives every terminal scheme. The render-clock cadence is 250 ms per frame, a fixed presentation rhythm alongside the sibling 100 ms status refresh, not a deployment choice. The running-status timer refreshes every 100 ms tick unconditionally rather than only when a streaming component exists, so the pulse animates even during the pre-first-token wait. + +The caret and its animation are their own `${indicator}` value, separate from the `${symbol}` label, so the `inputPrompt` template composes the two: `${symbol} ${indicator}` reads as `dsh `. Configurability lives at that template — a deployment reorders or drops either value, and omitting `${indicator}` opts out of the running indicator. The glyph set, the pulse, and the `dsh` label are fixed in code — not per-deployment fields — matching the fixed timing-bucket labels they mirror. + +The built-in `${symbol}`/`${indicator}` updates ride the renders the TUI already drives on every state change that can move a value (`agent/status`, session events, the 100 ms running-status timer, async model-context resolution). A prompt value that changes on its own schedule — a plugin-owned `${custom}` fragment — instead redraws through the registry's coalesced change notification, which the renderer subscribes to directly rather than through a Cordis event ([registry](2026-07-24-configurable-tui-prompt-theme.md)). + +## Alternatives considered + +**Prepend the glyph before `dsh>` as its own `${status}` token.** Rejected: a leading token shifts the whole prompt — and the cursor — right by two columns whenever it appears, and collapses back when it clears. Replacing the caret keeps the cursor column fixed. + +**A blinking glyph that appears and disappears.** Rejected: on/off blanking still moves nothing horizontally once the glyph owns the caret column, but the empty frames read as flicker. A brightness pulse animates continuously while the character stays put. + +**A per-phase spinner animation** (rotating frames). Rejected: the four phases are already distinguished by their glyph shapes; swapping the character per frame would conflate "which phase" with "still working". The pulse animates intensity while the shape stays a stable phase signal, reusing the existing 100 ms status timer. + +**A new phase state machine in the TUI.** Rejected: the header-timing machinery already replays the open step's active bucket from session events. Deriving the glyph from that bucket keeps one source of truth for "what phase is this step in". + +## Consequences + +The user gets a live, glanceable phase signal in the caret they are already watching, with no horizontal movement of the cursor or the prompt. The pulse costs terminal renders on every 100 ms tick for the whole running turn, not only while a streaming component is mounted. The glyph mapping and the pulse are fixed in code, not configurable, matching the fixed timing-bucket labels they mirror. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md new file mode 100644 index 0000000000..0dee8d1e4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-status-indicator.zh.md @@ -0,0 +1,33 @@ +# Agent Note:TUI 提示区状态指示器 + +Status: implemented + +[English](2026-07-24-tui-prompt-status-indicator.md) | 中文 + +## 问题 + +轮次运行期间,输入提示区只显示其静态的 `dsh>` 前缀。assistant 头部承载已用计时,但编辑器所在的这一行——也就是用户注意力所在之处——对 agent 此刻正在做什么没有任何实时信号:是在等待第一个 token、思考、响应,还是在运行工具。 + +## 决策 + +agent 运行期间,一个按阶段区分的字形会替换内置 `${indicator}` 提示区值中的 `>` 光标符。`inputPrompt` 主题模板默认为 `${symbol} ${indicator}`,其中内置 `${symbol}` 值承载 `dsh` 标签,`${indicator}` 承载光标符槽位及其在光标前的尾随间隙;模板中的字面空格将两者隔开,在每种状态下渲染为 `dsh <字形> `。阶段取自当前打开步骤的活跃计时桶,其派生所依据的会话事件与规则和[消息头部计时](2026-07-24-tui-message-header-timing.md)相同——没有引入新的阶段模型。每个桶对应一个字形:`◍` 等待模型(第一个 token 之前)、`✻` 思考、`●` 响应、`⚙` 工具。运行中但没有打开步骤的轮次回退到等待模型的字形;agent 空闲时则恢复为纯 `>`。 + +字形占据光标符所在的同一列,且每一帧的显示宽度都相同,因此无论阶段切换还是字形动画,光标都不会移动。活动状态由亮度脉动传达,而不是靠出现和消失:一个四帧三角波(暗 → 正常 → 亮 → 正常)用真正的 SGR 强度码(2 与 1)包裹带 accent 色的字形——绝不使用调色板语义上的 `dim` 角色,因为在浅色 scheme 下它是一种颜色,会被字形自身的 accent 色覆盖——因此脉动在任何终端 scheme 下都能保留。渲染时钟节拍为每帧 250 ms,是与配套的 100 ms 状态刷新并列的固定呈现节奏,而非部署选项。运行状态计时器每 100 ms 无条件刷新一次,而不再只在存在流式组件时刷新,因此即使在第一个 token 之前的等待期间,脉动也能持续。 + +光标符及其动画自成一个 `${indicator}` 值,与 `${symbol}` 标签分离,因此 `inputPrompt` 模板将二者组合:`${symbol} ${indicator}` 读作 `dsh <光标符>`。可配置性位于该模板——部署可重排或丢弃任一值,省略 `${indicator}` 即退出运行指示器。字形集、脉动以及 `dsh` 标签都固定在代码中——不是逐部署字段——与它们映射的固定计时桶标签一致。 + +内置 `${symbol}`/`${indicator}` 的更新搭乘 TUI 本就在每次可能改变某个值的状态变化(`agent/status`、会话事件、100 ms 运行状态计时器、异步模型上下文解析)时驱动的渲染。而一个自行变化的值——插件拥有的 `${custom}` 片段——则通过注册表的合并变更通知重绘,而渲染器直接订阅它,而非通过 Cordis 事件(参见[注册表](2026-07-24-configurable-tui-prompt-theme.md))。 + +## 考虑过的替代方案 + +**把字形作为自己的 `${status}` token 前置在 `dsh>` 之前。** 已否决:前置 token 每次出现都会把整个提示区——连同光标——向右移动两列,清除时又缩回。在尾随的 `${indicator}` 槽位替换光标符能让光标列保持固定。 + +**出现又消失的闪烁字形。** 已否决:一旦字形占据光标符所在列,开/关式的空白帧在水平方向上不再移动任何东西,但空帧读起来像闪烁。亮度脉动让字符保持不动的同时持续做动画。 + +**按阶段的 spinner 动画**(旋转帧)。已否决:四个阶段已经通过各自的字形形状区分;逐帧切换字符会把「哪个阶段」与「仍在工作」混为一谈。脉动只改变强度做动画,而形状始终是稳定的阶段信号,且复用了既有的 100 ms 状态计时器。 + +**在 TUI 中新建阶段状态机。** 已否决:头部计时机制已从会话事件回放出当前打开步骤的活跃桶。从该桶派生字形,能让「这个步骤处于哪个阶段」保持单一事实来源。 + +## 后果 + +用户在自己本就注视的光标符处获得可一眼掌握的实时阶段信号,且光标与提示区都没有水平移动。脉动的代价是整个运行轮次内每 100 ms 一次的终端渲染,而不再只在流式组件挂载期间。字形映射与脉动都固定在代码中、不可配置,与其所对应的固定计时桶标签一致。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.i18n.yaml new file mode 100644 index 0000000000..dd09857974 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.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-24-tui-prompt-workspace-label.md: c45c60c6554766cca01076b229956a7bc7d98d48 +2026-07-24-tui-prompt-workspace-label.zh.md: 170dba83becf4b529679e7db9c7c84a6de7dec13 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md new file mode 100644 index 0000000000..c45c60c655 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.md @@ -0,0 +1,34 @@ +# Agent Note: The prompt context combines directory and branch + +Status: implemented + +English | [中文](2026-07-24-tui-prompt-workspace-label.zh.md) + +## Problem + +The idle prompt context rendered the working directory and `git:` as separate segments. In task worktrees, the directory can already identify the checkout, while the prefixed branch segment consumed additional horizontal space and was discarded independently on narrower terminals. + +## Decision + +- The prompt context renders the working directory and available Git branch as one workspace label: ` ()`. +- The directory remains bold and accented; the parenthesized branch remains muted. +- The combined workspace label has the highest retention priority and is clipped as one segment when it exceeds the terminal width. +- Outside a Git worktree or on detached HEAD, the label remains the directory alone. + +## Alternatives considered + +**Keep `git:` as a separate segment.** Rejected: the prefix and separator use more columns without adding meaning in this context. + +**Show only the branch.** Rejected: the session working directory determines where tools operate and remains the primary prompt context. + +**Derive a special worktree root label.** Rejected: the existing formatted directory and Git branch already provide the two relevant facts without adding repository-layout assumptions. + +## Consequences + +- A typical checkout renders as `~/git/tui-staging (tui-staging)`. +- Narrow terminals retain or clip directory and branch together instead of dropping the branch independently. +- Embedding-provided `TuiRuntime.formatCwd` labels compose with the branch in the same form. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins home, absolute, formatted, and narrow workspace labels. Package-local and runnable-example TUI snapshots verify the assembled prompt context. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md new file mode 100644 index 0000000000..170dba83be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-prompt-workspace-label.zh.md @@ -0,0 +1,34 @@ +# Agent Note:提示区上下文合并显示目录与分支 + +Status: implemented + +[English](2026-07-24-tui-prompt-workspace-label.md) | 中文 + +## 问题 + +空闲提示区上下文(prompt context)把工作目录和 `git:` 作为两个独立片段渲染。在任务 worktree 中,目录本身往往已能标识当前检出,而带前缀的分支片段额外占用横向空间,且在较窄的终端上会被单独丢弃。 + +## 决策 + +- 提示区上下文把工作目录和可用的 Git 分支渲染为一个工作区标签(workspace label):` ()`。 +- 目录仍为加粗强调色;括号内的分支仍为弱化色。 +- 合并后的工作区标签具有最高保留优先级,超出终端宽度时作为一个整体片段裁剪。 +- 不在 Git worktree 中或处于 detached HEAD 时,标签仍只显示目录。 + +## 考虑过的替代方案 + +**保留 `git:` 作为独立片段。** 否决:前缀和分隔符占用更多列宽,在此上下文中却不增加信息。 + +**只显示分支。** 否决:会话工作目录决定工具在哪里运行,仍是提示区上下文的首要信息。 + +**派生一个特殊的 worktree 根目录标签。** 否决:现有的格式化目录和 Git 分支已经提供了这两项相关信息,无需引入对仓库布局的假设。 + +## 后果 + +- 典型的检出渲染为 `~/git/tui-staging (tui-staging)`。 +- 窄终端把目录和分支作为整体保留或裁剪,而不是单独丢弃分支。 +- 嵌入方通过 `TuiRuntime.formatCwd` 提供的标签以同样的形式与分支组合。 + +## 测试 + +`packages/ui/tui/tests/tui.spec.ts` 固定了主目录、绝对路径、格式化及窄终端下的工作区标签。包内快照与可运行示例的 TUI 快照验证了组装后的提示区上下文。 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.i18n.yaml new file mode 100644 index 0000000000..bfb40b9cc8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.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-24-tui-shell-prompt-editor.md: bba03e788b92692f534fd97e66035757e9c74356 +2026-07-24-tui-shell-prompt-editor.zh.md: 1897d11292ec3b189245169956ac327f0b81b0e2 diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md new file mode 100644 index 0000000000..bba03e788b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell-prompt editor + +Status: implemented + +English | [中文](2026-07-24-tui-shell-prompt-editor.zh.md) + +## Problem + +The upstream pi-tui editor always renders horizontal frame rows. That presentation separates input from the transcript but occupies two terminal rows and does not resemble the command-oriented input used by shells. + +## Decision + +The TUI presents a two-line prompt. A DSH-owned context line shows the working directory, running-turn timing, optional Git branch, current model, token totals, cache hit rate, and context pressure as independently prioritized segments. Narrow terminals omit lower-priority segments while retaining the directory, followed by running timing when it is present. The second line uses a fixed-width `dsh> ` prefix and equal-width continuation indent; its running steer/cancel guidance is placeholder text that disappears when input begins. + +The pinned `@earendil-works/pi-tui` package carries a pnpm patch that adds `frame: "none"` and fixed-width prompt prefixes to `EditorOptions`. The default remains the upstream horizontal frame, so only the DSH editor opts into the behavior. Prefixes must have equal visible widths; construction fails when they differ. Input, explicit newlines, autocomplete, cursor placement, and scroll indicators share the reduced first-row width; automatically wrapped rows render no prefix, so their text starts at the editor's left padding, occupies the prefix columns, and wraps at the full content width. + +The patch stays limited to the published editor JavaScript and declarations. Keeping the exact dependency pin makes installation either apply the known patch or fail rather than silently dropping the presentation. + +## Alternatives considered + +**Filter the rendered editor output in a wrapper.** This would depend on recognizing ANSI-styled border and scroll-indicator rows and distinguishing autocomplete output from input output, all of which are undocumented render details. + +**Vendor the complete pi-tui package.** The project updates frequently, while this change needs only a localized editor rendering option. Owning the full source and synchronization process would add disproportionate maintenance. + +**Keep the horizontal frame.** This avoids dependency customization but retains the presentation the change is intended to replace. + +## Consequences + +The editor and context use two rows instead of the framed editor plus footer, with one blank row separating the prompt area from conversation cards. The persistent presentation omits session identity and tool-card mode; `/status` and commands retain those details. Input layout and autocomplete lose six columns to the prompt prefix, but wrapped text uses the otherwise blank prefix columns. Borderless scrolling uses standalone `↑ N more` and `↓ N more` rows. + +The internal segment representation establishes width priorities without exposing a public customization language. Future Starship-like configuration can build on it after the default modules and overflow behavior have production evidence. + +A pi-tui upgrade requires reviewing and reapplying or retiring the patch. TUI terminal snapshots pin the assembled presentation, including context modules, prompt color, alignment, cursor placement, and autocomplete width. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md new file mode 100644 index 0000000000..1897d11292 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-shell-prompt-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note: TUI shell 提示符编辑器 + +Status: implemented + +[English](2026-07-24-tui-shell-prompt-editor.md) | 中文 + +## 问题 + +上游 pi-tui 编辑器始终渲染横向边框行。这种呈现方式虽然把输入区与 transcript(文本记录)分隔开,却占用两行终端高度,也不像 shell 中面向命令的输入形态。 + +## 决策 + +TUI 呈现两行提示符。DSH 自有的上下文行把工作目录、运行中轮次的计时、可选的 Git 分支、当前模型、token 总量、缓存命中率与上下文压力显示为各自独立分配优先级的段(segment)。窄终端会省略低优先级的段,但保留目录;运行中计时存在时,其保留优先级仅次于目录。第二行使用固定宽度的 `dsh> ` 前缀与等宽的续行缩进;agent 运行期间提示 steering(中途引导)与取消的引导文字是占位文本,开始输入后即消失。 + +固定版本的 `@earendil-works/pi-tui` 包(package)携带一个 pnpm 补丁,为 `EditorOptions` 增加 `frame: "none"` 与固定宽度的提示符前缀。默认值仍是上游的横向边框,因此只有 DSH 编辑器选择启用该行为。两个前缀的可见宽度必须相等;宽度不同时构造会失败。输入、显式换行、自动补全、光标定位和滚动指示共用缩减后的首行宽度;自动折行产生的行不渲染前缀,其文本从编辑器左侧留白处开始,占用前缀列,并按完整内容宽度折行。 + +补丁范围仅限已发布的编辑器 JavaScript 与类型声明。依赖保持精确的版本固定,使安装要么应用已知补丁,要么直接失败,而不会静默丢掉这种呈现方式。 + +## 曾考虑的替代方案 + +**在包装层过滤编辑器的渲染输出。** 这需要识别带 ANSI 样式的边框行与滚动指示行,并区分自动补全输出与输入输出,而这些都是未见于文档的渲染细节。 + +**vendor 完整的 pi-tui 包。** 该项目更新频繁,而本次改动只需要一个局部的编辑器渲染选项。接手全部源码及其同步流程会带来不成比例的维护成本。 + +**保留横向边框。** 这可以避免定制依赖,但保留的正是本次改动想要替换的呈现方式。 + +## 后果 + +编辑器与上下文共占两行,取代原先带边框的编辑器加页脚,提示符区域与对话卡片之间以一行空行分隔。常驻呈现不含会话标识与工具卡片模式;`/status` 与各命令仍保留这些细节。输入布局与自动补全因提示符前缀占位而损失六列宽度,但折行后的文本会占用原本留空的前缀列。无边框滚动使用独立的 `↑ N more` 与 `↓ N more` 行。 + +段的内部表示确立了宽度优先级,而未暴露公开的定制语言。待默认模块与溢出行为积累生产环境证据后,未来可在其上构建类似 Starship 的配置。 + +升级 pi-tui 时需要评审该补丁,并重新应用或将其退役。TUI 终端快照固定组装后的呈现效果,包括上下文模块、提示符颜色、对齐、光标定位和自动补全宽度。 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.i18n.yaml new file mode 100644 index 0000000000..dab40896a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.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 .agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md +2026-07-27-assistant-timing-header-trailing.md: a315a0620c63f25660220e55cf5187d7117c14d1 +2026-07-27-assistant-timing-header-trailing.zh.md: 84b8612337a345912371e37952195e4602f7ca25 diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md new file mode 100644 index 0000000000..a315a0620c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +English | [中文](2026-07-27-assistant-timing-header-trailing.zh.md) + +## Problem + +The TUI assistant message opened with a single header line joining the `Assistant` label and the step-timing string (`Assistant · Model wait 0.0s · Completed …`). Placing the timing before the body pushed the durations away from the answer they describe and, once completed, buried the reply's first line under a metadata line the reader scans past. + +## Decision + +**Split the label from the timing; render the timing as the message's trailing line.** + +`AssistantMessageComponent` (packages/ui/tui/src/index.ts) now emits the bold `Assistant` label as the first line and appends the dim timing string (already assembled by `StreamingAssistantComponent.rebuild()` as `header`, including the `· Completed …` suffix when settled) as the last child, after reasoning and text. The timing content, bucket-hiding, and completion-time behavior are unchanged — only its position moved from the top to the bottom of the message. + +## Alternatives considered + +**Move the whole header line (label included) to the end.** Rejected: the `Assistant` label orients the reader to who is speaking and belongs at the top like the `You` label; only the timing metadata benefits from trailing placement. + +**Keep the timing inline but below the label as a second top line.** Rejected: that still separates the durations from the completed answer and keeps two metadata lines between the prompt and the reply. + +## Consequences + +Each assistant message reads label → reasoning → answer → timing, so completed timing sits next to the reply it measures. The keyless TUI snapshot suite was refreshed to pin the new layout across every fixture; four `tui.spec.ts` assertions that matched the old inline `Assistant · Model wait …` string now assert the label and timing separately, since the two no longer render contiguously. diff --git a/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md new file mode 100644 index 0000000000..84b8612337 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-assistant-timing-header-trailing.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Assistant timing line renders after the message body + +Status: implemented + +[English](2026-07-27-assistant-timing-header-trailing.md) | 中文 + +## Problem + +TUI 的助手消息此前以一行开头,把 `Assistant` 标签和步骤计时串拼在一起(`Assistant · Model wait 0.0s · Completed …`)。计时放在正文之前,使耗时数据远离它所描述的回答;一旦完成,回复的首行还被读者会略过的元数据行压在下面。 + +## Decision + +**把标签与计时拆开;计时作为消息的末行渲染。** + +`AssistantMessageComponent`(packages/ui/tui/src/index.ts)现在把加粗的 `Assistant` 标签作为首行,并把暗色的计时串(仍由 `StreamingAssistantComponent.rebuild()` 组装为 `header`,settled 时含 `· Completed …` 后缀)作为最后一个子节点,追加在 reasoning 与正文之后。计时内容、隐藏零值桶以及完成时间的行为均不变——仅位置从消息顶部移到底部。 + +## Alternatives considered + +**把整行表头(含标签)都移到末尾。** 否决:`Assistant` 标签让读者知道是谁在说话,应与 `You` 标签一样置顶;只有计时这类元数据才受益于置底。 + +**计时仍内联,但作为标签下方的第二行置顶。** 否决:这仍把耗时数据与完成的回答分离,并在提示与回复之间保留两行元数据。 + +## Consequences + +每条助手消息按 标签 → reasoning → 回答 → 计时 阅读,完成计时紧挨它所度量的回复。无密钥的 TUI 快照套件已刷新,在每个 fixture 中固定新布局;`tui.spec.ts` 中四处原先匹配旧内联串 `Assistant · Model wait …` 的断言,现改为分别断言标签与计时,因为两者不再连续渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml new file mode 100644 index 0000000000..47416d5a30 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125 +2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md new file mode 100644 index 0000000000..847b7a7c78 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -0,0 +1,51 @@ +# Agent Note: Native workspace directory picker + +Status: implemented + +English | [中文](2026-07-27-native-workspace-directory-picker.zh.md) + +## Problem + +The desktop GUI asks users to type an absolute path when they add an existing workspace. This is slower and more error-prone than choosing a directory with the operating system's native picker. The GUI is delivered through the local Web carrier, so opening a native dialog also creates a privileged boundary that ordinary remote requests must not cross. + +## Decision + +Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session. + +The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title. + +## Interaction contract + +- The picker accepts one directory on macOS, Windows, and Linux. +- Cancelling the system dialog is silent and returns `null`. +- A duplicate path selects the existing workspace. +- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions. +- Other picker failures show a compact retryable error. +- The existing create-by-name flow remains unchanged. + +## Host boundary + +The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. + +Platform adapters invoke native tools without a shell: + +- macOS: `osascript` and the system folder chooser. +- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. + +## Alternatives considered + +- A custom directory browser duplicates operating-system behavior and permissions, and belongs to the Web implementation rather than this desktop-only change. +- Reusing the manual path field keeps the current error-prone interaction. +- Adding authentication infrastructure for one local native dialog would expand the change beyond its threat model; loopback and same-origin checks are sufficient for this carrier. + +## Consequences + +The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available. + +The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. + +## Risks + +- Linux desktop environments may provide neither supported picker. The GUI reports that limitation instead of falling back to a typed path. +- Browser metadata varies outside the supported local carrier. The endpoint intentionally rejects requests that cannot prove the required local same-origin context. diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md new file mode 100644 index 0000000000..e484a2ba58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 原生工作区目录选择器 + +Status: implemented + +[English](2026-07-27-native-workspace-directory-picker.md) | 中文 + +## 问题 + +桌面端 GUI 在添加现有工作区时要求用户输入绝对路径。相比使用操作系统原生选择器选取目录,这种操作速度更慢,也更容易出错。GUI 由本地 Web 载体提供,因此打开原生对话框也会形成一条特权边界,普通远程请求不得越过这条边界。 + +## 决策 + +新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。 + +工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。 + +## 交互契约 + +- 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。 +- 取消系统对话框不会显示提示,并返回 `null`。 +- 路径重复时,选中现有工作区。 +- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。 +- 选择器的其他故障会显示简洁且可重试的错误提示。 +- 现有的按名称创建流程保持不变。 + +## 宿主边界 + +只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 + +平台适配器不经 shell,直接调用原生工具: + +- macOS:`osascript` 和系统文件夹选择器。 +- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 + +## 考虑过的替代方案 + +- 自定义目录浏览器会重复实现操作系统的行为和权限逻辑,而且应属于 Web 实现,而非本次仅面向桌面端的变更。 +- 继续使用手动路径字段会保留当前容易出错的交互方式。 +- 为一个本地原生对话框添加身份认证基础设施,会使变更范围超出其威胁模型;对当前载体而言,回环与同源检查已经足够。 + +## 后果 + +当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。 + +新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 + +## 风险 + +- Linux 桌面环境可能不提供任何一种受支持的选择器。GUI 会报告这项限制,而不会回退到要求用户输入路径。 +- 在受支持的本地载体之外,浏览器元数据可能有所不同。对于无法证明其满足所需本地同源上下文的请求,该端点会按设计拒绝。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.i18n.yaml new file mode 100644 index 0000000000..2012fedaaa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.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 .agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md +2026-07-27-tui-running-glyph-smooth-fade.md: e4c8fee399c2269bfe53976d3358bc643b2daf6a +2026-07-27-tui-running-glyph-smooth-fade.zh.md: 25bda3d549b1a7548e997f8801858d1efa32e3eb diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md new file mode 100644 index 0000000000..e4c8fee399 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +English | [中文](2026-07-27-tui-running-glyph-smooth-fade.zh.md) + +## Problem + +While a turn runs, the TUI replaces the `>` prompt caret with a phase glyph (`◍`/`✻`/`●`/`⚙`). Earlier iterations animated its brightness in the accent blue (a discrete SGR wave, then a truecolor throb) — a colored, always-pulsing indicator. The desired effect keeps the continuous pulse to signal ongoing work, but as a quiet dim gray rather than a color, and with smooth fade-in and fade-out at its edges. + +## Decision + +The running glyph is a dim gray that fades in on turn start, throbs continuously while the turn runs, and fades out after it ends before the plain `>` caret returns. It is never the accent color. + +Brightness is a fade envelope times a running throb. The envelope gates appear/disappear, linear in the render clock over `STATUS_FADE_MS = 300`: `(now − startedAt)/FADE` clamped for fade-in, `1 − (now − endedAt)/FADE` for fade-out. `pulseLevel` is a cosine between `STATUS_PULSE_FLOOR` (0) and 1 over `STATUS_PULSE_PERIOD_MS = 1400`, so each breath swells from fully invisible to full and back. The truecolor opacity handed to `fadeGlyph` is `envelope × pulse`. + +`fadeGlyph` renders at that opacity. With truecolor, below `STATUS_FADE_MIN_OPACITY` (0.12) the glyph is hidden entirely — a blank column — so the pulse trough disappears rather than lingering as a near-background gray; above it the glyph interpolates a 24-bit gray between `STATUS_FADE_GRAY.trough` and `.settled` (the same dim gray as the idle caret), emitting `\x1b[38;2;r;g;bm`, so both the fade and the throb are brightness. Without truecolor there is no per-frame gray, so a separate `visible` flag — driven by the envelope alone, not the pulsing opacity — shows the glyph in the palette's muted role or leaves a blank column; the throb never blinks the fallback. With color off entirely a visible glyph is bare, preserving the caret column on a monochrome terminal. + +The running prompt refreshes at `STATUS_ANIMATION_INTERVAL_MS = 50` (~20 fps) so the throb moves every frame; the same tick keeps the 0.1 s-resolution elapsed text current, so no separate timing timer exists. + +Fade-out outlives the turn: on the running → non-running edge `beginFadeOut` hands the last rendered glyph to a `FadingStatus` whose own timer re-renders until the fade window elapses, then calls `clearStatus` and restores `>`. Teardown paths (dispose, agent-disposed, startup-failure) call `clearStatus` directly, stopping both the running and fading timers at once — no lingering fade. The glyph handed to the fade-out is the last live phase glyph (`runningStatus.lastGlyph`), not the ttft fallback the phase derivation returns once the closing turn's step has ended. + +The glyph character and its cell never change — only the gray brightness — so the caret column stays fixed across frames and across the caret↔glyph transitions. + +## Alternatives considered + +**Keep the accent color.** The pulse is wanted, but as a quiet gray matching the idle caret's tone, not a colored indicator; the accent is removed while the throb stays. + +**Hold steady while running (no throb).** A steady dim glyph was tried and rejected: a continuous pulse better conveys that the agent is actively working. The throb returns, in gray. + +**A non-zero floor that keeps the trough faintly visible.** Successive floors (0.45 → 0.15 → 0.02) each kept the dimmest point too visible to read as truly quiet; even 0.02 sat at gray ≈ 45, one step off the background. A floor of 0 with an explicit visibility threshold (`STATUS_FADE_MIN_OPACITY`) instead hides the glyph entirely at the bottom of each breath, so the trough is genuinely absent. Because the swell is a smooth cosine, the disappearance reads as a soft fade-out, not the hard on/off blink a low-but-nonzero gray toggle would give. + +**Pulse the non-truecolor fallback too.** SGR exposes only three intensity levels, too coarse for a smooth throb, and toggling the glyph on/off across the pulse would blink it. The fallback instead shows a steady muted glyph gated by the envelope; only truecolor terminals get the throb. + +## Consequences + +The running glyph reads as a quiet gray breath that swells from nothing to a dim mark and back the whole turn, matching the idle caret's tone, at the cost of a faster render tick (50 ms) while a turn is active or fading out; the diffing terminal only re-emits changed cells, so the extra frames are cheap. The fade-out means the indicator lingers ~300 ms after a turn completes. Snapshots run non-truecolor with a frozen clock, so they pin only the steady muted glyph (envelope-gated), not the throb; the truecolor invisible trough, the settled peak, a rising mid-frame, the fade-out, and the non-truecolor appear/disappear are pinned by unit tests in `tui.spec.ts`. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md new file mode 100644 index 0000000000..25bda3d549 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-running-glyph-smooth-fade.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Dim-gray pulse for the running prompt glyph + +Status: implemented + +[English](2026-07-27-tui-running-glyph-smooth-fade.md) | 中文 + +## Problem + +回合运行时,TUI 会把 `>` 提示符替换为阶段字形(`◍`/`✻`/`●`/`⚙`)。此前的迭代用强调蓝为其亮度做动画(先是离散 SGR 波,后是 truecolor 呼吸)——一个持续脉动的彩色指示器。期望的效果保留持续脉动以示正在工作,但改为安静的暗灰而非颜色,并在两端做平滑的淡入淡出。 + +## Decision + +运行字形是一种暗灰色,在回合开始时淡入,运行期间持续脉动,回合结束后淡出,随后恢复为普通的 `>` 光标。它从不使用强调色。 + +亮度是淡入淡出包络乘以运行脉冲。包络控制出现/消失,随渲染时钟在 `STATUS_FADE_MS = 300` 内线性变化:淡入为 `(now − startedAt)/FADE` 并做钳制,淡出为 `1 − (now − endedAt)/FADE`。`pulseLevel` 是在 `STATUS_PULSE_FLOOR`(0)与 1 之间、周期为 `STATUS_PULSE_PERIOD_MS = 1400` 的余弦,因此每次呼吸都从完全不可见涨到满亮再回落。交给 `fadeGlyph` 的 truecolor 不透明度为 `envelope × pulse`。 + +`fadeGlyph` 以该不透明度渲染。在 truecolor 下,低于 `STATUS_FADE_MIN_OPACITY`(0.12)时字形被完全隐藏——留出空白列——因此脉冲谷值消失,而非停留为接近背景的灰;在其之上,字形在 `STATUS_FADE_GRAY.trough` 与 `.settled`(与空闲光标相同的暗灰)之间插值出 24 位灰色,发出 `\x1b[38;2;r;g;bm`,因此淡入与脉冲都表现为亮度。没有 truecolor 时不存在逐帧灰度,因此用一个单独的 `visible` 标志——只由包络驱动,而非脉动的不透明度——以调色板 muted 角色显示字形或留出空白列;脉冲从不使回退闪烁。完全关闭颜色时,可见字形以裸字符呈现,在单色终端上保住光标列。 + +运行提示符以 `STATUS_ANIMATION_INTERVAL_MS = 50`(约 20 fps)刷新,使脉动逐帧移动;同一次 tick 也让 0.1 s 精度的耗时文本保持最新,因此不需要单独的计时器。 + +淡出会延续到回合之后:在运行 → 非运行的边沿,`beginFadeOut` 把最后渲染的字形交给一个 `FadingStatus`,其自有计时器持续重绘,直到渐变窗口结束,然后调用 `clearStatus` 并恢复 `>`。拆解路径(dispose、agent-disposed、启动失败)直接调用 `clearStatus`,一次性停止运行与淡出两个计时器——不会有残留的渐变。交给淡出的字形是最后一次的实时阶段字形(`runningStatus.lastGlyph`),而非收尾回合的步骤结束后阶段推导返回的 ttft 兜底字形。 + +字形字符及其单元格从不改变——只有灰色亮度变化——所以光标列在各帧之间以及光标↔字形的切换之间都保持固定。 + +## Alternatives considered + +**保留强调色。** 需要脉冲,但要用与空闲光标一致的安静灰色,而非彩色指示器;移除强调色,保留脉动。 + +**运行时保持稳定(不脉动)。** 曾试过稳定的暗色字形并被否决:持续脉动更能表明代理正在积极工作。脉动以灰色回归。 + +**用非零下限让谷值保持微弱可见。** 逐次下限(0.45 → 0.15 → 0.02)都让最暗点太可见,读不出真正的安静;即便 0.02 也停在灰度约 45,仅比背景高一档。改用下限 0 加显式可见阈值(`STATUS_FADE_MIN_OPACITY`),在每次呼吸的底部完全隐藏字形,使谷值真正缺席。由于涨落是平滑余弦,消失读作柔和的淡出,而非低而非零的灰度开关会带来的硬性开/关闪烁。 + +**让非 truecolor 回退也脉动。** SGR 只暴露三个强度档位,做平滑脉动太粗糙,而按脉冲开关字形会使其闪烁。回退改为由包络控制的稳定 muted 字形;只有 truecolor 终端获得脉动。 + +## Consequences + +代价是运行或淡出期间渲染 tick 更快(50 ms),换来的是运行字形整段回合读作一种从无涨到暗记号再回落的安静灰色呼吸,与空闲光标的色调一致;差分终端只重发变化的单元格,因此额外帧开销很低。淡出意味着指示器在回合结束后残留约 300 ms。快照以非 truecolor、冻结时钟运行,因此只钉住由包络控制的稳定 muted 字形,而非脉动;truecolor 的不可见谷值、稳定峰值、上升中间帧、淡出、以及非 truecolor 的出现/消失均由 `tui.spec.ts` 的单元测试钉住。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml new file mode 100644 index 0000000000..820645dc95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.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 .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 +2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md new file mode 100644 index 0000000000..0868d8dcaf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +English | [中文](2026-07-27-tui-tool-card-header.zh.md) + +## Problem + +The TUI rendered each tool call as `{glyph} {title}`, where `title` was the presenter's fused verb-plus-detail string (`Read src/index.ts (1200-1360)`, `Edit files`, or a bash card's model description), bold and underlined in the status color. One flat slot carried the tool identity, the target, and the status at once, and the styling mixed bold, underline, and color inconsistently — the header read as noise, and which tool ran was not visually separable from what it operated on. + +## Decision + +The header is a fixed `{ring} Tool / ` frame in a single flat status color — no bold, no underline, no dim — so one color reads consistently across the whole row. `Tool` is a literal constant; `` is the raw tool name. The separator is ASCII `/`. The ring marker is `○` while the call is pending and `●` once it settles; the header color (warning pending / success ok / error) distinguishes pending from ok from error, so the same filled ring serves both settled states. + +The header carries exactly one optional extra: a bash (terminal) card's model-authored description, appended as a ` / ` segment (`● Tool / bash / Run the coverage gate`). No other tool contributes a header detail. + +Every tool-specific detail moves into the body block below the header. A non-terminal card's presenter title (`Read src/index.ts`, `Grep pattern`) becomes the first body line, unless it only repeats the tool name (the fallback presenter for a tool with no `presentCall`, or an unknown tool), which the header already shows. A terminal card keeps its command as the `$`-line. A diff card drops its title entirely — the per-file path headers and a change footer carry the meaning — and appends a dim `└ +A -R · N file(s)` footer summarizing added/removed line counts across the files. + +The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src/components/transcript.ts` and no presenter: the `Tool / ` frame derives the name TUI-side from the call's tool name, and the body-title relocation reuses the presenter title already returned. `presentation.ts` and every `presentCall`/`presentResult` are unchanged. + +## Alternatives considered + +**Bold the name to make it stand out.** Rejected: on terminals that render SGR-1 as the bright color variant, a bold green name reads as a different color from the rest of the green header — reintroducing the inconsistency the redesign removes. The name stands out by position in the fixed frame, not by weight. + +**Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc. + +**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. + +## Consequences + +A tool call now shows its identity in one stable place, and status reads as one flat color per row, so a transcript of many calls scans as a column of `Tool / ` rather than a wall of mixed-styled verb strings. The cost is one extra body line for non-terminal tools (the relocated title) and the loss of the earlier redundancy-suppression that omitted a diff's per-file path when the header already named it — the header no longer names any path, so every diff prints its path once. Because the change is confined to `ToolCardComponent`, other UI bridges (ACP, JSON-RPC) keep their own tool-call presentation; the `Tool / ` shape is TUI-local and not part of any cross-package contract. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the new header (`Tool / `), the dropped diff title, the relocated generic title, and the `· N file(s)` footer. The keyless terminal snapshots under `packages/ui/tui/tests/snapshots/` and `examples/tui-agent/tests/snapshots/` — rendered through the real assembled TUI and a pseudo-terminal — were re-recorded and show the new cards for read, bash (described and undescribed), edit, and the other tools. diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md new file mode 100644 index 0000000000..71db5fc6fc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Fixed `Tool / ` header for tool-call cards + +Status: implemented + +[English](2026-07-27-tui-tool-card-header.md) | 中文 + +## Problem + +TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 presenter 拼接的「动词加细节」字符串(`Read src/index.ts (1200-1360)`、`Edit files`,或 bash 卡片的模型描述),以状态色加粗并加下划线显示。单一扁平的槽位同时承载了工具身份、操作对象和状态,而样式又混用了加粗、下划线和颜色,前后不一致——表头读起来像噪声,「运行了哪个工具」在视觉上与「它操作了什么」无法区分。 + +## Decision + +表头是固定的 `{ring} Tool / ` 框架,采用单一扁平的状态色——不加粗、不加下划线、不变暗——因此整行的颜色保持一致。`Tool` 是字面常量;`` 是原始工具名。分隔符是 ASCII 的 `/`。环形标记在调用挂起时为 `○`,落定后为 `●`;表头颜色(挂起用 warning、成功用 success、错误用 error)区分挂起、成功与错误,因此同一个实心环可同时服务于两种落定状态。 + +表头只携带一个可选的额外内容:bash(终端)卡片由模型撰写的描述,作为 ` / ` 段追加(`● Tool / bash / Run the coverage gate`)。其他工具都不向表头贡献细节。 + +每一项工具专属的细节都移入表头下方的正文块。非终端卡片的 presenter 标题(`Read src/index.ts`、`Grep pattern`)成为正文第一行,除非它只是重复工具名(无 `presentCall` 的工具的兜底 presenter,或未知工具),此时表头已经显示过。终端卡片保留其命令作为 `$` 行。diff 卡片完全弃用其标题——由各文件的路径表头与一条变更页脚承载含义——并追加一条变暗的 `└ +A -R · N file(s)` 页脚,汇总各文件增删的行数。 + +本次改版仅限 TUI。它改动 `packages/ui/tui/src/components/transcript.ts` 中的 `ToolCardComponent`,不触碰任何 presenter:`Tool / ` 框架在 TUI 侧从调用的工具名推导出名称,正文标题的迁移则复用 presenter 已返回的标题。`presentation.ts` 以及每一个 `presentCall`/`presentResult` 均保持不变。 + +## Alternatives considered + +**把工具名加粗使其突出。** 已否决:在把 SGR-1 渲染为亮色变体的终端上,加粗的绿色工具名读起来与其余绿色表头是不同的颜色——重新引入了改版本要消除的不一致。工具名靠它在固定框架中的位置突出,而非靠字重。 + +**把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 + +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 + +## Consequences + +工具调用现在把身份显示在一个稳定的位置,状态每行读作一种扁平色,于是许多调用的记录扫读起来是一列 `Tool / `,而非一堵混合样式的动词字符串之墙。代价是非终端工具多出一行正文(迁移过来的标题),以及丢失了先前的冗余抑制——当表头已命名路径时省略 diff 的各文件路径;如今表头不再命名任何路径,因此每个 diff 都会把路径打印一次。由于改动局限于 `ToolCardComponent`,其他 UI 桥(ACP、JSON-RPC)保留各自的工具调用呈现;`Tool / ` 的形态是 TUI 局部的,不属于任何跨包契约。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 固定了新表头(`Tool / `)、弃用的 diff 标题、迁移后的 generic 标题以及 `· N file(s)` 页脚。`packages/ui/tui/tests/snapshots/` 与 `examples/tui-agent/tests/snapshots/` 下的无密钥终端快照——经由真实组装的 TUI 与伪终端渲染——已重新录制,展示了 read、bash(有描述与无描述)、edit 及其他工具的新卡片。 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml new file mode 100644 index 0000000000..b27ba457af --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.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-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 +2026-07-23-personal-staging-maintenance-skills.zh.md: db1595c83da0ad93e9ba9055b5a3d7fe7cfe1706 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md new file mode 100644 index 0000000000..a7ccc5b1e0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md @@ -0,0 +1,35 @@ +# Agent Note: Personal staging maintenance skills + +Status: implemented + +English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) + +## Problem + +Personal dsh customizations need a repeatable way to locate the installed source, isolate task work, serialize integration, and incorporate upstream changes without rewriting the checkout used by running sessions. User-local instructions solve this for one installation but cannot guide other users or remain synchronized with repository installer behavior. + +## Decision + +The repository distributes [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md), [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md), and [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md) from its root `skills/` directory. Their descriptions name both the operation and user requests that select it. The shipped TUI supplies that directory to the local skill provider at startup, below project and user roots in discovery priority. The workflows derive the active checkout and staging branch from the installed launcher rather than a user-specific path or branch name, defer to repository-local instructions, require task worktrees, and serialize staging mutations with the staging worktree's established `.agents/merge.lock`. + +Before rebasing, an upgrade inspects the Git log and commit ranges to identify incoming upstream changes, personal commits, duplicates, and likely conflicts. It drops customizations already supplied upstream; when only a documentary local diff remains for such a customization, it also drops that account unless it adds an independently useful current contract absent upstream. Each attempt uses one UTC basic timestamp for its independent `dsh-staging-` sibling clone, local `dsh-upgrade/prepare-` branch, new `dsh-staging/` branch, private upstream and recovery refs, and launcher backup. The sibling name does not derive from the current directory name, and collisions fail rather than acquiring ad hoc suffixes. The workflow derives the current DSH process source from the process command and runtime environment rather than the shell working directory, then treats the repository and checkout behind the installed launcher as immutable except for holding its existing merge lock. + +After validation in the independent clone, the workflow creates and verifies the timestamped staging branch, then atomically moves the launcher once from the unchanged old staging checkout to the new staging checkout. The launcher never targets a preparation, feature, review, publication, or detached checkout. Failure before cutover leaves the installed checkout and launcher unchanged; failure after cutover restores and verifies the launcher backup. The old staging checkout, its branch, the recovery ref, and the launcher backup remain available until a restarted process proves that DSH runs from the new staging branch and the user explicitly approves rollback cleanup. + +`dsh-upstream-customization` owns upstream publication independently from local maintenance and upgrades. It recommends bug fixes, additive non-conflicting plugin features, and visual improvements; intrusive changes require maintainer approval first. At the end of an upgrade, the agent classifies remaining customizations, explains their upstream value, recommends whether to propose each one, and asks which named candidate the user wants to upstream. Only that selection loads the publication workflow; each feature still requires explicit approval before a push or draft PR. Approved changes start from current upstream `master` without unrelated personal commits. Draft PRs for TUI features preferably include a screenshot from the assembled application after credentials and personal data are removed. `dsh-customize` requires interactive TUI behavior to be exercised in a dedicated tmux session before integration. + +## Alternatives considered + +**Keep the workflows user-scoped.** This preserves personal flexibility but prevents other users from discovering the same safety rules and lets the workflow drift from the installer shipped by the repository. + +**Rebase the active staging checkout in place.** This is simpler but changes many files during preparation, can disrupt new dsh launches, and cannot provide atomic publication or an unchanged rollback checkout. + +**Update the existing staging checkout after moving the launcher elsewhere.** This retains one staging path but requires a mid-upgrade launcher target that is not a staging branch and still rewrites a checkout that may host a running process. + +**Lock only the final branch switch.** This shortens lock duration but permits a customization merge against the old base while the rebase is being prepared, invalidating the prepared history. + +**Open one upstream PR for all personal changes.** This reduces branch management but publishes unrelated customizations and removes the user's per-feature approval boundary. + +## Consequences + +Upgrade preparation holds the installed staging merge lock while dependencies and checks run, so local customization integration waits for a consistent result. One upgrade creates an independent timestamped clone and staging branch, performs one atomic launcher cutover, and requires one restart afterward; it never writes into the repository or checkout behind the launcher except to hold its existing lock. Each workflow records preconditions, repeats them before mutation, inspects state after interrupted mutations, restores the launcher backup on cutover failure, reruns failed checks after correction, and reports final state. The old staging checkout remains rollback storage until explicit user-approved cleanup. Checked-in evaluations cover selection, process-source protection, unsafe repository states, rollback, and publication authorization; repository documentation checks validate skill links and formatting, while technical review remains responsible for Git and filesystem correctness. diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md new file mode 100644 index 0000000000..db1595c83d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 个人集成分支维护 skill(技能) + +Status: implemented + +[English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 + +## 问题 + +个人 dsh 定制需要一套可重复执行的方法,用于定位已安装的源码、隔离各项任务的修改、串行集成变更,并在不改写运行中会话所用检出的前提下合入上游变更。用户本地指令能解决某一套安装中的问题,却无法指导其他用户,也无法持续与仓库安装脚本的行为保持同步。 + +## 决策 + +仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill 的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 + +升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩说明性差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前契约。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-` 同级克隆、本地 `dsh-upgrade/prepare-` 分支、新的 `dsh-staging/` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 + +在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 + +`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上完整应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 + +## 备选方案 + +**将这些工作流限定在用户本地。** 这样可以保留个人使用的灵活性,但其他用户无法发现同一套安全规则,工作流也可能逐渐偏离仓库分发的安装脚本行为。 + +**在当前集成分支检出中原地变基。** 此方案更简单,但准备期间会修改大量文件,可能干扰新的 dsh 启动,也无法实现原子发布或提供一份保持不变的回滚检出。 + +**在将启动器迁往别处后更新现有的集成分支检出。** 此方案可以保留单一的集成分支路径,却要求在升级中途让启动器指向一个并非集成分支的目标,且仍会改写可能承载运行中进程的检出。 + +**只在最终切换分支时加锁。** 这样可以缩短持锁时间,却允许写入方在变基准备期间继续基于旧基线合并定制变更,导致准备好的历史失效。 + +**用一个上游 PR 发布所有个人变更。** 这会减少分支管理工作,却会发布无关的定制,并取消用户按功能逐项批准的边界。 + +## 影响 + +升级准备流程在安装依赖和运行检查期间持有已安装集成分支的合并锁,因此本地定制的集成必须等待一致的结果。一次升级会创建独立的带时间戳的克隆和集成分支,执行一次原子的启动器切换,并在切换后要求重启一次;除持有其既有锁之外,升级绝不会写入启动器所指向的仓库或检出。各工作流会记录前置条件、在修改前重复检查、在修改被中断后检查状态、在切换失败时恢复启动器备份、修复后重新运行失败的检查,并报告最终状态。旧的集成分支检出会作为回滚存储一直保留,直到用户明确批准清理为止。仓库内评估覆盖 skill 选择、进程源码保护、不安全的仓库状态、回滚和发布授权;仓库文档检查会验证 skill 的链接和格式,Git 与文件系统操作的正确性仍由技术评审负责。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 8bc3f87435..e8fdb3f515 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 -2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e +2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 4cc603daa52bc9e6b0a85e33086a559a21dcc621 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index 61b7a829b8..3217b405e9 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -30,13 +30,13 @@ Areas record semantic repository domains rather than temporary initiatives, owne ### Current areas -The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index cc0c5e7a89..4cc603daa5 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -30,13 +30,13 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 ### 当前领域 -当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 998417a651..b7e099dec5 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml @@ -1,6 +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-26-frozen-agent-note-archive.md: e829d30853c7b80dee76da0fdc22db7e9b04e828 -2026-07-26-frozen-agent-note-archive.zh.md: f90a81561eb4c11c8d48400d2adfbcfff7e6a9ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 +2026-07-26-frozen-agent-note-archive.zh.md: 9362fbcb268045f0cb6762bf6804a54eed34caee diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index e829d30853..52b43088b2 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -20,12 +20,16 @@ After archival, the triplet is permanently frozen and is historical context rath The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review. +Supersession is checked while a new Agent Note is being written, not deferred to a later corpus cleanup. The author compares the new note with active notes covering the same decision, mechanism, or rejected alternative and classifies every full or partial supersession. Qualifying implemented triplets are archived in the same pull request; partial supersessions and independently useful rationale remain active and cross-linked, while proposed and rejected matches follow their own lifecycle rules. + ## Alternatives considered **Delete every note that leaves the active corpus.** Rejected because an implemented record can have low forward guidance while still providing useful historical evidence about a closed decision. A content-sealed archive preserves that evidence without pretending it remains current. **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. + **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. **Continue applying all documentation gates to archived notes.** Rejected because a later formatting, translation, code, package, or link rule would require rewriting the historical snapshot. The dedicated verifier owns completeness and immutability instead. @@ -34,4 +38,4 @@ The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index f90a81561e..9362fbcb26 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -20,12 +20,16 @@ implemented Agent Note(agent 决策记录)作为当前决策记录持续维 [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。 +在编写新的 Agent Note 时就检查取代关系,而不是推迟到日后清理记录集合时再处理。作者会将新记录与涵盖同一项决策、机制或被否决备选方案的活跃记录进行比较,并逐项判定属于完全取代还是部分取代。符合条件的 implemented Agent Note 三文件配对会在同一个拉取请求中归档;仅部分被取代的记录,以及仍保有独立价值的决策依据,会继续作为活跃记录保留并与新记录互相链接,而匹配到的 proposed 和 rejected Agent Note 则遵循各自的生命周期规则。 + ## 曾考虑的替代方案 **删除每一份移出活跃记录集合的记录。** 不予采纳,因为已实施记录可能对未来的指导价值较低,却仍能为已经收尾的决策提供有用的历史证据。按内容 hash 封存的归档既能保留这些证据,又不会假装它们仍然反映当前状态。 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 + **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 **继续对归档 Agent Note 应用所有文档门禁。** 不予采纳,因为后续新增的格式、翻译、代码、包或链接规则会迫使维护者重写历史快照。改由专用校验器负责完整性与不可变性。 @@ -34,4 +38,4 @@ implemented Agent Note(agent 决策记录)作为当前决策记录持续维 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml new file mode 100644 index 0000000000..961eeb8f0d --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.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 .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +2026-07-27-dependabot-version-updates.md: 725649652c5b91ba4897d03b548b9aa5c3694c21 +2026-07-27-dependabot-version-updates.zh.md: 1ab34e76b84c7423be76fa79ae6bb3705a07a76c diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md new file mode 100644 index 0000000000..725649652c --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -0,0 +1,34 @@ +# Agent Note: Dependabot version updates with a 30-day cooldown + +Status: implemented + +English | [中文](2026-07-27-dependabot-version-updates.zh.md) + +## Problem + +Maintained registry and GitHub Actions dependencies need a regular update path. Adopting every release immediately increases exposure to compromised releases and early regressions, while leaving updates entirely manual lets dependency drift accumulate. Vendored Cordis sources and independently locked workspaces also cannot be treated as one undifferentiated package tree. + +## Decision + +The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, the independently locked `native/landlock-run` pnpm workspace, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. + +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md), and `native/landlock-run/**`, which its dedicated entry owns. GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. + +Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. + +The pnpm entries keep both workspaces on their pinned pnpm 11 instead of introducing an automation-only downgrade. The current Dependabot updater installs the version requested by `packageManager` and reads both workspaces' lockfile format `9.0`; the provider-run update job remains the integration check. + +## Alternatives considered + +- **Immediate version updates.** Rejected because they remove the requested release-age quarantine and make the project an early consumer of every upstream release. +- **Automatic merging after CI.** Rejected because dependency changes can alter runtime, build, and release behavior; the normal review decision remains part of accepting an update. +- **One recursive npm scan.** Rejected because it could admit vendored manifests or conflate the root and native lockfiles. Explicit exclusions and a dedicated native entry preserve their ownership boundaries. +- **Renovate or a scheduled agent.** Both can propose aged updates, but Dependabot is the requested service and the repository's CI already recognizes its pull requests as an untrusted dependency source. +- **Cooldown exemptions for coordinated fresh releases.** Rejected for the automated path because those releases require an explicit synchronization or model-catalog decision rather than a generic update proposal. + +## Consequences + +- Routine dependency updates arrive in small reviewable pull requests after the quarantine instead of requiring periodic manual discovery. +- A release normally appears between 30 and 36 days after publication because eligibility is evaluated weekly. +- Dependabot does not delay security proposals; repository checks can still block unrelated fresh transitives, and review preserves the vendoring boundary. +- Maintainers still decide whether to merge each update and diagnose any provider limitation reported by the pnpm 11 update job. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md new file mode 100644 index 0000000000..1ab34e76b8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -0,0 +1,34 @@ +# Agent Note: Dependabot 版本更新采用 30 天冷却期 + +Status: implemented + +[English](2026-07-27-dependabot-version-updates.md) | 中文 + +## 问题 + +来自包注册表的依赖与 GitHub Actions 依赖都需要定期更新机制。每个新版本一经发布便立即采用,会增加受到遭入侵的版本和早期回归影响的风险;但完全依靠手动更新,又会导致依赖版本差距持续扩大。以源码形式纳入仓库的 Cordis 与各自维护独立锁文件的工作区,也不能不加区分地视为同一棵包(package)树。 + +## 决策 + +默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为根 pnpm 工作区、独立维护锁文件的 `native/landlock-run` pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。 + +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更;扫描还排除由专用更新项负责的 `native/landlock-run/**`。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 + +仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 + +pnpm 更新项让两个工作区继续使用已固定的 pnpm 11,不会仅为了自动化而降级版本。当前 Dependabot 更新器会安装 `packageManager` 指定的版本,并读取两个工作区使用的 `9.0` 锁文件格式;由提供方运行的更新任务仍作为集成检查。 + +## 考虑过的替代方案 + +- **立即进行版本更新。** 不采用,因为这会取消所要求的版本发布后隔离期,使项目在每个上游版本的发布初期就采用该版本。 +- **CI 通过后自动合并。** 不采用,因为依赖变更可能改变运行时、构建和发布行为;是否接受更新仍须经过常规评审决策。 +- **使用一次递归 npm 扫描。** 不采用,因为它可能将随源码纳入仓库的 manifest 纳入更新范围,或混淆根工作区与 native 工作区的锁文件。显式排除项和专用 native 更新项可维持各自的归属边界。 +- **Renovate 或定期运行的 agent(智能体)。** 二者都能为发布已满一定时长的版本提出更新,但所要求的服务是 Dependabot,而且仓库 CI 已将其 PR 视为不可信的依赖来源。 +- **为需协调的刚发布版本设置冷却期豁免。** 自动化路径不采用,因为此类版本需要明确的同步决策或模型目录决策,不能由通用更新提案代替。 + +## 后果 + +- 隔离期结束后,常规依赖更新会以规模较小、便于评审的 PR 形式到达,无需维护者定期手动发现更新。 +- 由于每周评估一次更新资格,相应更新 PR 通常会在版本发布后 30 至 36 天出现。 +- Dependabot 不会延迟安全更新提案;仓库检查仍可阻止无关的刚发布传递依赖,评审流程也会维持 vendoring 边界。 +- 维护者仍负责决定是否合并每项更新,并诊断 pnpm 11 更新任务报告的任何提供方限制。 diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml new file mode 100644 index 0000000000..34dcf42c4f --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c +2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md new file mode 100644 index 0000000000..d18f6c1bf8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -0,0 +1,41 @@ +# Agent Note: Make Lefthook installation worktree-local + +Status: implemented + +English | [中文](2026-07-27-worktree-local-lefthook.zh.md) + +## Problem + +Every `pnpm install` runs the root [`postinstall`](../../../../package.json), whose [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) invokes `lefthook install --force`. Linked Git worktrees otherwise share the common repository's default hooks directory, so an install in any worktree can rewrite hooks used by every other worktree. + +Lefthook-generated hooks prefer an absolute binary path captured from the installing worktree before trying their current-worktree fallback. Shared hooks can therefore run another worktree's pinned binary until that worktree disappears, while concurrent installs write the same files. + +## Decision + +Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. + +Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. + +Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. + +If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback. + +## Alternatives considered + +**Keep the shared generated hooks and rely on their current-worktree fallback.** The captured absolute path wins while its worktree exists, so the fallback does not provide version or lifecycle isolation. + +**Point every worktree at one checked-in `.githooks` directory.** A relative tracked directory removes generated absolute paths, but changing the shared `core.hooksPath` can disable hooks in older worktrees whose branches do not contain that directory and still couples every worktree to one shared configuration value. + +**Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit. + +**Whitelist provider-specific CI credential-include paths.** Contributor hooks are unused in CI, so path exemptions would couple installer safety to provider checkout internals and weaken strict validation for contributor installs. The CI no-op avoids repository mutation without any exemptions. + +**Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees. + +## Consequences + +Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged. + +The repository becomes a Git format-1 repository after the first installation. The installer requires Git 2.26 for `--show-scope`; the worktree-config extension itself predates that command. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`. + +Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md new file mode 100644 index 0000000000..42a1625a3b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 让 Lefthook 安装限定于各 worktree + +Status: implemented + +[English](2026-07-27-worktree-local-lefthook.md) | 中文 + +## 问题 + +每次运行 `pnpm install` 都会执行根目录的 [`postinstall`](../../../../package.json),其中的 [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) 会调用 `lefthook install --force`。若无额外配置,关联的 Git worktree 共用同一仓库的默认钩子目录,因此在任一 worktree 中安装都可能改写其他所有 worktree 使用的钩子。 + +Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝对二进制文件路径,之后才尝试当前 worktree 的回退路径。因此,共享钩子会一直运行另一个 worktree 固定版本的二进制文件,直到该 worktree 消失;并发安装还会写入同一组文件。 + +## 决策 + +钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。 + +升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。 + +每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 + +若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。 + +## 考虑过的替代方案 + +**保留共享的生成钩子,并依赖其当前 worktree 回退路径。** 只要对应 worktree 仍存在,记录的绝对路径就会优先生效,因此回退路径无法提供版本或生命周期隔离。 + +**让每个 worktree 都指向同一个纳入版本控制的 `.githooks` 目录。** 使用受版本控制的相对目录可以消除生成的绝对路径,但更改共享的 `core.hooksPath` 可能会禁用旧 worktree 中的钩子,因为其分支并不包含该目录;同时,每个 worktree 仍然耦合于同一个共享配置值。 + +**构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。 + +**将特定 CI 提供商的凭据 include 路径加入白名单。** CI 不使用贡献者钩子,因此路径豁免会使安装程序的安全性耦合于提供商的检出目录内部结构,并削弱贡献者安装时的严格验证。CI 无操作方案无需任何豁免即可避免修改仓库。 + +**停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent(智能体)使用的 worktree 尤其容易受到影响。 + +## 后果 + +安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。 + +首次安装后,仓库会采用 Git 格式版本 1。安装程序需要 Git 2.26 来使用 `--show-scope`;worktree 配置扩展本身的出现早于该命令。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。 + +旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 83ddffc483..ca9654223d 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.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-remove-stream-chunk-mirror.md: 1d9ff86800521eb5ef226575e33a35dcaffd6f6e -2026-07-02-remove-stream-chunk-mirror.zh.md: 26dcc36038efd2857a90c15a675d843d57282ec1 +2026-07-02-remove-stream-chunk-mirror.md: cc75f71407a3c54d71b11e91cea2eb2658649b5e +2026-07-02-remove-stream-chunk-mirror.zh.md: 47e4c42daedadb09b6f51508af84e593ee8218a9 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index 1d9ff86800..cc75f71407 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -6,7 +6,7 @@ English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) ## Problem -The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart: +The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/agent.ts` the two sat one line apart: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 26dcc36038..47e4c42dae 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,二者仅相隔一行: +agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/agent.ts` 中,二者仅相隔一行: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) 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 e441d0bfb1..60181a2ddb 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 @@ -1,6 +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: 86c056b53700d0e0c02e04a99cf044fb311f5840 -2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +2026-07-17-one-send-one-turn.md: dcc6c0aa483a0e53205dbaeef4e2b903f5f6a215 +2026-07-17-one-send-one-turn.zh.md: 8c12481defe6608c13ee81132b432b4d8b17b681 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 86c056b537..dcc6c0aa48 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 @@ -20,11 +20,11 @@ Before enqueueing an item, `send()` checks the agent state and makes a detached, 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. +Prompt admission decides one message at a time before a turn opens. An allowed prompt becomes that turn's `user/message`; a blocked prompt is discarded without opening a turn or writing session history. 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. +The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; a request-error retry action or a later prompt takes it, while 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. +`inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends a `user/message` directly, without opening a turn or running the model. Persistence owns the resulting eager drain. `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, so `running` does not prove that a turn is open. ## Alternatives considered @@ -35,8 +35,8 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i - 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()`. +- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; rejected admission creates no turn, recorded turns stay balanced, messages do not merge, and surviving queued work still drains. +- Separate tests cover open-turn, failed-turn, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. ## Consequences 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 3ef9973480..8c12481def 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 @@ -20,11 +20,11 @@ Status: implemented 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 -提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 +提示词准入会在轮次打开前,每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词会被丢弃,不打开轮次,也不写入会话历史。实现中不存在混合批次或全阻止批次分支。 -上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering(中途引导)FIFO。只要当前轮次仍然打开,agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`、取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 +上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent;请求错误的重试动作或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 -`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 +`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 @@ -35,8 +35,8 @@ Status: implemented - 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 - stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 - 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 -- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 -- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 +- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;准入拒绝不会创建轮次,已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次失败后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.i18n.yaml new file mode 100644 index 0000000000..2f7bde6b26 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.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 .agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md +2026-07-24-agent-loop-observable-state-machine.md: 54730de8aa73342b609d423dc478edb076d7844b +2026-07-24-agent-loop-observable-state-machine.zh.md: 206ac701472f14823300df0c812f2cc818f852f5 diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md new file mode 100644 index 0000000000..54730de8aa --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.md @@ -0,0 +1,58 @@ +# Agent Note: Collapse agent-loop events around the observable state machine + +Status: implemented + +English | [中文](2026-07-24-agent-loop-observable-state-machine.zh.md) + +## Problem + +The agent loop exposed its control flow as a large set of Cordis events. Separate `pre-step` and `post-step` checkpoints bracketed a step, `session-prefix` and `step-result` transformed request and response messages, `request-error` decided whether a failed request retried inside its turn, and `turn-continuation` plus `turn-stop` composed competing continuation decisions. + +Those events made internal phases public even when the durable session log already owned the corresponding turn and step facts. They also mixed two extension models: some listeners observed a boundary and issued an agent command, while others returned control decisions that the loop interpreted. Understanding the public machine therefore required reconstructing event order, waterfall precedence, and special terminal overrides together. + +Agent lifetime, whole-agent activity, inbox-item progress, and per-turn settlement are independent state dimensions. Treating them as one status or one linear callback sequence makes ordinary questions ambiguous: an agent can remain `running` across several turns, an accepted item can be discarded without opening a turn, and one turn can settle while later work keeps the agent active. + +## Decision + +The public contract exposes four orthogonal state dimensions: + +- Registration lifetime is the `agent/created` to `agent/disposed` interval. Disposal is the terminal registry edge, not an `AgentStatus`. +- Whole-agent activity is `AgentStatus = 'idle' | 'running'`. Consecutive turns may share one `running` interval. +- A FIFO-backed message progresses from `agent/inbox/enqueue` to exactly one `agent/inbox/dequeue` or `agent/inbox/discard`, correlated by `AgentMessageId`. The inbox events describe acceptance, claim, and removal rather than turn completion. +- A claimed turn passes through prompt admission and zero or more request steps. An automatic retry closes the failed turn and immediately opens another; `agent/settled` reports only the terminal turn in that chain and remains distinct from the whole-agent transition to `status === 'idle'`. + +The loop keeps five machine extension events. `agent/prompt-submit` admits, rewrites, or blocks a claimed prompt. `agent/step` is the single awaited between-steps checkpoint and runs before every request is derived. `agent/request` is the waterfall for the frozen call configuration; the configuration comes only from `await next()`, not from a duplicate positional argument. `agent/request-error` serializes ownership of awaited model-request recovery. `agent/turn-stopping` runs when the turn otherwise has no work left; a listener that needs another step records real steering with `agent.steer()`, and the loop decides from that data after all listeners settle. + +Continuation and termination are data rather than returned control enums. Tool calls and accepted steering require another step. A tool result carrying `concludesTurn` ends the tool loop at its step. The loop does not expose general `ContinuationDecision` or terminal-stop return channels. + +A model-request failure closes its step, then enters `agent/request-error` with the exact error, normalized `LlmFailure`, and live turn signal. A listener that owns recovery repairs state, returns `{ kind: 'retry' }`, and stops delegating. The loop closes the failed turn and opens one retry turn over that state without an intervening idle notification; retry is not another step inside the failed turn. `agent/settled` reports the terminal outcome, and `agent/error` remains the live error notification for consumers that report failures independently of turn settlement. The [retry-action decision](2026-07-27-request-error-retry-action.md) supersedes the command-shaped part of this design. + +The event taxonomy removes `agent/pre-step`, `agent/post-step`, `agent/session-prefix`, `agent/step-result`, `agent/turn-continuation`, and `agent/turn-stop`. Durable turn and step boundaries remain session events. Model-facing additions use logged message channels, request configuration uses `agent/request`, response content is recorded as assembled, failed-request recovery uses the `agent/request-error` return action, and end-of-turn continuation uses `agent/turn-stopping` plus steering. + +## Alternatives considered + +**Keep the fine-grained event sequence.** This preserves a dedicated interception point for every internal phase, including request-only prefixes, assistant-message rewriting, post-step work, in-turn request recovery, and terminal stop overrides. It also makes the loop's private sequencing a permanent public contract and lets overlapping seams express conflicting decisions. The decision accepts the lost interception points in exchange for one boundary per supported extension responsibility. + +**Represent disposal as a third `AgentStatus`.** This gives retained handles a terminal status value but duplicates the registry lifecycle already expressed by `agent/disposed`. The decision keeps `AgentStatus` about live activity and makes registration lifetime a separate dimension. + +**Return a retry decision from `agent/request-error`.** This alternative is superseded by the [retry-action decision](2026-07-27-request-error-retry-action.md), which removes the duplicate command and keeps the decision local to the waterfall result. + +**Mirror durable turn and step boundaries as agent events.** This gives live consumers a second event stream for the same facts. The decision keeps the session log as the source of truth and exposes only extension checkpoints or live-only facts that the durable stream cannot carry. + +## Consequences + +The observable machine is smaller and compositional: registration lifetime, activity, item progress, and terminal settlement can be followed independently. In particular, `agent/settled` does not imply `agent.status === 'idle'`; it reports the terminal turn of one drain chain, while `agent/status` reports whether the whole agent is active. + +Plugins no longer rewrite every phase of the loop. There is no request-only message prefix, assistant-message transform, post-step checkpoint, generic continuation enum, generic terminal-stop result, or in-turn request retry. Extensions use the remaining owned channels instead of recreating those phases. + +Continuation plugins publish durable steering rather than returning an unlogged reason. Recovery plugins act after the failed step and return an explicit retry action. This makes every attempt a complete turn while keeping asynchronous repair and policy ownership at one narrow waterfall boundary. + +The inbox lifecycle complements, rather than replaces, the durable session log. `AgentMessageId` correlates acceptance with claim or discard; turn and step numbers, messages, tool activity, and terminal reasons remain session facts. + +## Related + +- [Unify agent delivery on send(target × wakeup) and coalesce injected context into user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [Remove implicit batching from ordinary sends](2026-07-17-one-send-one-turn.md) +- [Microkernel event taxonomy](../architecture/2026-06-11-microkernel-event-taxonomy.md) +- [Bounded LLM request recovery](../architecture/2026-06-21-bounded-llm-request-recovery.md) +- [Reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md) diff --git a/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md new file mode 100644 index 0000000000..206ac70147 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-24-agent-loop-observable-state-machine.zh.md @@ -0,0 +1,58 @@ +# Agent Note: 围绕可观察状态机收拢 agent loop(智能体循环)事件 + +Status: implemented + +[English](2026-07-24-agent-loop-observable-state-machine.md) | 中文 + +## 问题 + +agent loop 曾将其控制流暴露为大量 Cordis 事件。`pre-step` 和 `post-step` 两个独立检查点分列步骤前后,`session-prefix` 和 `step-result` 分别变换请求消息与响应消息,`request-error` 决定失败的请求是否在当前轮次内重试,`turn-continuation` 与 `turn-stop` 则组合相互竞争的继续执行决策。 + +即使持久会话日志已经记录了对应的轮次与步骤事实,这些事件仍会将内部阶段公开。它们还混用了两种扩展模型:部分监听器观察边界并发出 agent 命令,另一些监听器则返回由循环解释的控制决策。因此,要理解公开状态机,必须同时还原事件顺序、waterfall(瀑布式事件)优先级和特殊的终止覆盖规则。 + +agent 生命周期、agent 整体活动状态、收件箱条目的进度以及每轮次的结算,是彼此独立的状态维度。若将它们视为一个状态或一条线性回调序列,常见问题就会产生歧义:agent 可以在多个轮次之间持续保持 `running`;已接受的条目可以不启动轮次就被丢弃;一个轮次可以完成结算,而后续工作仍让 agent 保持活动。 + +## 决策 + +公开契约暴露四个正交的状态维度: + +- 注册生命周期是从 `agent/created` 到 `agent/disposed` 的区间。dispose(资源释放)是注册表的终止边界,而不是一种 `AgentStatus`。 +- agent 整体活动状态为 `AgentStatus = 'idle' | 'running'`。连续多个轮次可以共用同一个 `running` 区间。 +- 由 FIFO 支撑的消息从 `agent/inbox/enqueue` 开始,最终必然进入 `agent/inbox/dequeue` 或 `agent/inbox/discard` 二者之一,并通过 `AgentMessageId` 关联。收件箱事件描述接受、领取和移除,而不是轮次完成。 +- 已领取的轮次经过提示词准入和零个或多个请求步骤。自动重试会关闭失败轮次并立即开启另一个轮次;`agent/settled` 只报告该重试链的终态轮次,且仍不同于 agent 整体转换到 `status === 'idle'`。 + +循环保留五个状态机扩展事件。`agent/prompt-submit` 对已领取的提示词执行准入、改写或阻断。`agent/step` 是步骤之间唯一需要等待的检查点,在每次派生请求前运行。`agent/request` 是冻结调用配置所用的 waterfall;配置只能来自 `await next()`,不再通过重复的位置参数提供。`agent/request-error` 串行确定需要等待的模型请求恢复由谁负责。当轮次原本已经没有剩余工作时,`agent/turn-stopping` 运行;需要再执行一个步骤的监听器使用 `agent.steer()` 记录真实的 steering(中途引导),循环在所有监听器完成后根据这份数据作出决定。 + +是否继续和终止执行由数据表达,不再由返回的控制枚举表达。工具调用和已接受的 steering 要求再执行一个步骤。携带 `concludesTurn` 的工具结果会在其所属步骤终止工具循环。循环不再暴露通用的 `ContinuationDecision` 或终止停止返回通道。 + +模型请求失败会先关闭当前步骤,再携带准确错误、标准化 `LlmFailure` 和仍有效的轮次信号进入 `agent/request-error`。负责恢复的监听器修复状态、返回 `{ kind: 'retry' }`,并停止继续委托。循环会关闭失败轮次,并基于该状态开启一个重试轮次,中间不发布空闲通知;重试不是失败轮次内的另一个步骤。`agent/settled` 报告终态结果;对于需要脱离轮次结算单独报告失败的消费方,`agent/error` 仍作为实时错误通知保留。[重试动作决策](2026-07-27-request-error-retry-action.md)取代了本设计中命令形式的部分。 + +事件分类体系移除了 `agent/pre-step`、`agent/post-step`、`agent/session-prefix`、`agent/step-result`、`agent/turn-continuation` 和 `agent/turn-stop`。持久的轮次与步骤边界仍由会话事件记录。面向模型的新增内容使用有日志记录的消息通道,请求配置使用 `agent/request`,响应内容按组装后的原样记录,失败请求恢复使用 `agent/request-error` 返回动作,轮次结束时是否继续则使用 `agent/turn-stopping` 加 steering 表达。 + +## 考虑过的替代方案 + +**保留细粒度事件序列。** 这样可以为每个内部阶段保留专用拦截点,包括仅用于请求的前缀、助手消息改写、步骤后处理、轮次内请求恢复以及终止停止覆盖。但这也会使循环的私有执行顺序成为永久的公开契约,并允许相互重叠的 seam 表达彼此冲突的决策。当前决策接受这些拦截点的缺失,以换取每项受支持的扩展职责仅对应一个边界。 + +**将 dispose 表示为第三种 `AgentStatus`。** 这样会让仍被持有的句柄得到一个终止状态值,但也会重复表达 `agent/disposed` 已经体现的注册表生命周期。当前决策让 `AgentStatus` 只表示活动中 agent 的状态,并将注册生命周期作为独立维度。 + +**让 `agent/request-error` 返回重试决策。** 这一替代方案已由[重试动作决策](2026-07-27-request-error-retry-action.md)取代;新决策移除了重复命令,并将决策局限于 waterfall 的返回结果。 + +**将持久的轮次与步骤边界映射为 agent 事件。** 这样会为同一事实向实时消费方提供第二条事件流。当前决策将会话日志保留为真源,仅暴露扩展检查点或持久事件流无法承载的纯实时事实。 + +## 影响 + +可观察状态机更小,也更容易组合:注册生命周期、活动状态、条目进度和终态结算可以分别追踪。尤其是,`agent/settled` 并不意味着 `agent.status === 'idle'`;前者报告一次排空链的终态轮次,`agent/status` 则报告整个 agent 是否处于活动状态。 + +插件不再能够改写循环的每个阶段。不再提供仅用于请求的消息前缀、助手消息变换、步骤后检查点、通用的继续执行枚举、通用的终止停止结果或轮次内请求重试。扩展改用剩余的归属明确的通道,而不是重新构造这些阶段。 + +负责继续执行的插件发布可持久化的 steering,而不是返回未记录到日志中的原因。恢复插件在失败步骤结束后处理错误,并返回显式重试动作。这样,每次尝试都会成为完整轮次,同时异步修复和策略归属集中在一个狭窄的 waterfall 边界。 + +收件箱生命周期用于补充持久会话日志,而非取代它。`AgentMessageId` 将接受操作与领取或丢弃操作关联起来;轮次编号与步骤编号、消息、工具活动和终止原因仍属于会话事实。 + +## 相关内容 + +- [统一通过 send(target × wakeup) 交付 agent 消息,并将注入上下文合并到 user/message](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) +- [移除普通发送中的隐式批处理](2026-07-17-one-send-one-turn.md) +- [微内核事件分类体系](../architecture/2026-06-11-microkernel-event-taxonomy.md) +- [有界 LLM 请求恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md) +- [可重建的请求](../architecture/2026-07-05-reconstructable-requests.md) diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.i18n.yaml new file mode 100644 index 0000000000..6575dbed79 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.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 .agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md +2026-07-27-copyable-transcript-no-gutter-bar.md: 659b7d2f2f85bf7efe6b1006a2c61a14f3044560 +2026-07-27-copyable-transcript-no-gutter-bar.zh.md: 5c43169ba352ef1fef73ecf2188a32304aba9c26 diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md new file mode 100644 index 0000000000..659b7d2f2f --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.md @@ -0,0 +1,34 @@ +# Agent Note: Copyable TUI transcript without gutter bars + +Status: implemented + +English | [中文](2026-07-27-copyable-transcript-no-gutter-bar.zh.md) + +## Problem + +The TUI grouped user prompts and tool cards behind a colored left-gutter bar (`▌ `) prepended to every body line, and indented assistant and system blocks by one column. Both are per-line prefixes: a terminal mouse drag-select over the scrollback captures the leading `▌ ` or the leading space on each line, so copy-paste of a message, a tool's output, or a code block pulls in decoration the user must strip by hand. The bar was the transcript's only per-message separator, so it could not simply be dropped without another way to tell messages apart. + +## Decision + +The scrollback carries no per-line prefix. Messages are separated only by a bold, underlined role header in the role color and blank-line spacing, both of which the terminal already inserts around each block. The underline gives each role a distinct visual band without a background fill, so it reads on any terminal theme and never enters the clipboard: + +- User and steering prompts (`UserMessageComponent`) are a plain `Container`: a bold, underlined accent `You` / `Steering` header line (via the shared `messageHeader` helper), then the prompt body at column 0. +- Assistant blocks render a bold, underlined `Assistant` header, then reasoning and text at column 0, with the timing line at the end of the block (the former `paddingX = 1` indent is gone). +- Tool cards drop the `GutterBox` wrapper. The card status (pending / error / success) colors the whole title line — the status glyph (`◌` / `✕` / `✓`) plus the title text share one color, bold and underlined to match the role headers — instead of a colored bar beside an uncolored title. The body renders unprefixed; body lines still pass through `Text` at the terminal width so overlong raw tool output wraps rather than overflowing. +- The `GutterBox` class is deleted; nothing else used it. + +A drag-select over any of these regions now copies exactly the message text. + +## Alternatives considered + +- **Keep the bar only on user messages, drop it on tool cards** — leaves tool output, the most-copied region, still polluted. Rejected: the goal is a wholly copyable transcript. +- **A single top rule or bar on the header line only** — the body copies clean, but selecting the header still captures a glyph, and it reintroduces a decoration character for no distinguishing gain over the underlined role header. +- **Indent grouped bodies instead of a bar** — leading spaces still enter the clipboard, so it does not solve the copy problem; explicitly ruled out. +- **A filled background band on the header** (reverse video, or a 256-color muted background) — gives each role a strong color block, but the saturated ANSI fill reads as too heavy and the 256-color shades are fixed rather than theme-remapped. The underline gives per-role distinction with a far lighter footprint. + +## Consequences + +- Copy-paste from the scrollback is clean with no user post-processing. This was the motivating win. +- The transcript is flatter than the gutter-bar layout, but each role's bold, underlined header in the role color plus blank-line spacing keeps message boundaries clear without any left-edge fill. Tool-card status stays legible through the colored, underlined glyph and title. +- Box-drawing borders (`│`) on transient overlays — status panel, model selector, resume list — are untouched. They are not scrollback message content and are rarely copied. +- The affected keyless TUI `*.expected.txt` snapshots were re-recorded by fixture replay (no API key needed; the recorded LLM sessions are unchanged, only the render differs). Interactive boot and a round-trip prompt were verified in tmux. diff --git a/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md new file mode 100644 index 0000000000..5c43169ba3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-copyable-transcript-no-gutter-bar.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 无 gutter bar 的可复制 TUI transcript + +Status: implemented + +[English](2026-07-27-copyable-transcript-no-gutter-bar.md) | 中文 + +## Problem + +TUI 此前把用户提示词和工具卡片分组在一条彩色左侧 gutter bar(`▌ `)之后,该竖条被逐行加在每一行正文前面,并把 assistant 与系统块整体缩进一列。两者都是逐行前缀:在 transcript 上用鼠标框选时,每一行开头的 `▌ ` 或前导空格都会被一并选中,因此复制一条消息、一段工具输出或一个代码块时都会带上装饰字符,用户必须手动清理。该竖条又是 transcript 中唯一的逐条消息分隔标记,所以不能在没有其他区分方式的情况下直接删掉。 + +## Decision + +transcript 不再带任何逐行前缀。消息仅通过以角色色渲染的粗体带下划线角色标题和空行分隔,而这两者本就由终端在每个块前后自动插入。下划线让每个角色获得清晰的视觉分带,且无需背景填充,因此在任何终端配色下都可读,也绝不会进入剪贴板: + +- 用户提示词与 steering 提示词(`UserMessageComponent`)改为普通 `Container`:一行粗体带下划线的强调色 `You` / `Steering` 标题(经共享的 `messageHeader` 辅助函数生成),随后是位于第 0 列的提示词正文。 +- Assistant 块渲染一行粗体带下划线的 `Assistant` 标题,随后 reasoning 与文本均在第 0 列渲染,timing 行位于块末尾(原先的 `paddingX = 1` 缩进已移除)。 +- 工具卡片去掉 `GutterBox` 包装层。卡片状态(进行中 / 错误 / 成功)对整行标题着色——状态字形(`◌` / `✕` / `✓`)与标题文本共用一种颜色,并同角色标题一样加粗且带下划线——而不再是未着色标题旁的一条彩色竖条。正文无前缀渲染;正文行仍按终端宽度经 `Text` 处理,使过长的原始工具输出换行而非溢出。 +- `GutterBox` 类被删除;没有其他地方使用它。 + +现在对上述任一区域框选,复制得到的正是消息文本本身。 + +## Alternatives considered + +- **仅在用户消息上保留竖条、在工具卡片上去掉** —— 会让最常被复制的工具输出仍然带有污染。已否决:目标是让整个 transcript 都可复制。 +- **仅在标题行上加一条顶部横线或竖条** —— 正文复制干净,但选中标题时仍会带上一个字形,且相比带下划线的角色标题并未带来额外的区分收益,却重新引入了装饰字符。 +- **用缩进代替竖条对分组正文缩进** —— 前导空格仍会进入剪贴板,无法解决复制问题;已明确排除。 +- **在标题上使用填充背景带**(反色,或 256 色柔和背景)—— 能给每个角色一块强烈的色块,但饱和的 ANSI 填充观感过重,且 256 色是固定色而非随主题重映射。下划线以远更轻的方式提供了同样的逐角色区分。 + +## Consequences + +- 从 transcript 复制粘贴无需用户做任何后处理。这正是本次改动的核心收益。 +- transcript 比 gutter bar 布局更扁平,但每个角色以角色色渲染的粗体带下划线标题加空行分隔,无需任何左缘填充即可让消息边界保持清晰。工具卡片状态仍通过彩色带下划线的字形与标题保持可读。 +- 临时浮层(状态面板、模型选择器、恢复列表)上的制表符边框(`│`)保持不变。它们不属于 transcript 消息内容,且很少被复制。 +- 受影响的 keyless TUI `*.expected.txt` 快照均通过 fixture 回放重新记录(无需 API 密钥;所记录的 LLM 会话未变,仅渲染不同)。交互式启动与一次往返提示已在 tmux 中验证。 diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.i18n.yaml new file mode 100644 index 0000000000..e22645e088 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.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 .agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md +2026-07-27-request-error-retry-action.md: 3057b9fa28cf203c9374930fe97421918b4c1a6f +2026-07-27-request-error-retry-action.zh.md: bcb4e592f0c3d86f896e279cf0e3ea400741a1bb diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md new file mode 100644 index 0000000000..3057b9fa28 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.md @@ -0,0 +1,29 @@ +# Agent Note: Request-error retry action + +Status: implemented + +English | [中文](2026-07-27-request-error-retry-action.zh.md) + +## Problem + +Model-request recovery was decided inside `agent/request-error` but communicated through `Agent.retry()`. That public command was valid during one narrow waterfall window and while idle, rejected other running states, and required `ReactLoopAgent` to retain a mutable retry window beside the waterfall result. The recovery plugins were the only production callers, so the wider live-agent capability exposed states and behavior unrelated to their policy decision. + +## Decision + +`agent/request-error` returns `RequestErrorAction`, whose handling action is `{ kind: 'retry' }`; the default `undefined` keeps the failed turn terminal. A listener that does not own the failure calls `next()`. A listener that owns it performs any awaited repair and returns the retry action without delegating. + +The loop reads the action after the waterfall settles, closes the failed turn, and opens one retry turn from durable history. It rechecks the turn signal when consuming the action, so cancellation or disposal during recovery prevents the retry even if a listener returns it afterward. A thrown recovery never produces an action. + +`Agent` and `ReactLoopAgent` expose no `retry()` method. Ordinary new work enters through `send()` and its `followup()`, `steer()`, and `inject()` presets; only a handled model-request failure can open a promptless retry turn. + +## Alternatives considered + +**Keep `Agent.retry()` as the recovery command.** Runtime guards can restrict the command to the request-error window, but the interface still advertises an idle resummon operation with no production consumer and the loop still needs mutable side-channel state to recover a decision already owned by the waterfall. + +**Return an explicit terminal action.** `undefined` already represents the waterfall's unhandled default and composes directly through `next()`. A second `{ kind: 'fail' }` value would add no distinct behavior or ownership information. + +## Consequences + +Recovery ownership, asynchronous repair, and the retry decision share one typed return path. The live-agent interface and concrete loop lose the idle resummon capability and retry-window state. Callers cannot restart arbitrary failed non-request work without submitting a later prompt, while transient and context-overflow policies retain numbered retry turns, durable-history reconstruction, finite private budgets, and cancellation precedence. + +Focused agent-loop tests pin retry chaining, terminal fallthrough, recovery failure, and cancellation races. The llm-retry and compact-basic suites pin their policy-owned action returns, and the ACP, goal-session, and plan-mode integrations pin successor-turn adoption. diff --git a/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md new file mode 100644 index 0000000000..bcb4e592f0 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-27-request-error-retry-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 请求错误重试动作 + +Status: implemented + +[English](2026-07-27-request-error-retry-action.md) | 中文 + +## 问题 + +模型请求恢复由 `agent/request-error` 内部决定,却通过 `Agent.retry()` 传达。这个公开命令只在一个狭窄的 waterfall(瀑布式事件)窗口内和空闲时有效,在其他运行状态下会被拒绝,并要求 `ReactLoopAgent` 在 waterfall 结果旁保留一个可变的重试窗口。恢复插件是仅有的生产调用方,因此更宽泛的活跃 agent(智能体)能力暴露了与其策略决策无关的状态与行为。 + +## 决策 + +`agent/request-error` 返回 `RequestErrorAction`,其中负责处理的动作是 `{ kind: 'retry' }`;默认的 `undefined` 会让失败轮次保持终态。不拥有该失败的监听器调用 `next()`。拥有该失败的监听器执行所有需要等待的修复,然后直接返回重试动作而不继续委托。 + +waterfall 结算后,循环读取该动作,关闭失败轮次,并从持久历史开启一个重试轮次。循环在使用该动作时会再次检查轮次信号,因此即使监听器随后返回重试动作,恢复期间发生的取消或资源释放仍会阻止重试。抛出异常的恢复不会产生动作。 + +`Agent` 与 `ReactLoopAgent` 均不暴露 `retry()` 方法。普通新工作通过 `send()` 及其 `followup()`、`steer()` 和 `inject()` 预设进入;只有已处理的模型请求失败才能开启没有提示词的重试轮次。 + +## 曾考虑的替代方案 + +**保留 `Agent.retry()` 作为恢复命令。** 运行时防护检查可以将该命令限制在请求错误窗口内,但接口仍会暴露一个没有生产消费方的空闲无提示词再运行操作,循环也仍需通过可变的旁路状态恢复已经由 waterfall 决定的结果。 + +**返回显式终态动作。** `undefined` 已经表示 waterfall 未处理时的默认值,并可直接通过 `next()` 组合。再添加一个 `{ kind: 'fail' }` 值不会提供不同的行为或归属信息。 + +## 后果 + +恢复归属、异步修复和重试决策共用一条类型化返回路径。活跃 agent 接口与具体循环不再具有空闲无提示词再运行能力和重试窗口状态。调用方如果不提交后续提示词,就无法重启任意失败的非请求工作;瞬时策略与上下文溢出策略则保留编号重试轮次、从持久历史重建、有限的策略私有预算和取消优先级。 + +聚焦的 agent-loop 测试固定了重试链、未处理失败保持终态、恢复失败和取消竞态。llm-retry 与 compact-basic 测试套件固定其策略自有的动作返回,而 ACP、goal-session 和 plan-mode 集成测试固定后继轮次承接。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md deleted file mode 100644 index 652c3d410a..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.md +++ /dev/null @@ -1,75 +0,0 @@ -# Agent Note: Separate context injection from turn execution - -Status: proposed - -English | [中文](2026-07-24-separate-context-injection-from-turn-execution.zh.md) - -## Problem - -The agent API currently represents supplementary model-facing input in three overlapping ways: callers attach `HookContext[]` through `SendOptions.contexts`, interception and tool hooks return `additionalContexts`, and plugins call `agent.inject()`. These paths eventually write context into the same model history, but they carry different placement, metadata, admission, queue, and turn-lifecycle rules. - -Atomic attachment to an inbox message forces the loop to preserve context through prompt admission, steering conversion, cancellation, and terminal discard. `prompt-prefix` placement then combines context and the direct prompt into one event, requiring a model-hidden envelope so transcript consumers can recover what the user actually wrote. The result makes outbox entries, session projection, and UI replay responsible for a distinction that belongs to the producer. - -Idle `inject()` exposes a second mismatch. Injection does not request model execution, yet the current implementation opens and closes a zero-step `injection` turn solely to satisfy the turn-enclosure invariant and obtain a durability checkpoint. A turn therefore sometimes means “run the agent loop” and sometimes means “persist context without running it.” - -`HookContext` also names its producer rather than its role. The value may come from a native plugin, a hook bridge, prompt admission, or tool post-processing. Its stable meaning is simply additional model-facing context with provenance. - -## Proposal - -Make `inject()` the only caller-facing operation for adding supplementary model-facing input, and define a turn exclusively as one execution of the model loop. - -Remove `SendOptions.contexts`. A caller that owns context delivers it with `inject()` and independently submits the direct message with `send()` or `steer()`. Rename `HookContext` to `AdditionalContext`; retain only `content` and `source`, and remove placement and model-hidden metadata from this shared shape. - -Prompt and tool extension points may still return `additionalContexts`. These values are outputs of the extension point, not attachments captured from a caller's inbox item. Prompt admission runs before `run()` opens a turn. An allowed prompt enters the outbox together with its returned additional contexts; a blocked prompt writes neither and opens no turn. Tool-produced additional contexts enter the same outbox after the corresponding tool results. - -Every additional context becomes an independent `user/message` whose `source` records provenance. Remove `context/message`, prompt-prefix placement, the stable request delimiter, and the prompt envelope. Transcript and UI consumers distinguish direct user messages from injected context by `source`, not by recovering a hidden direct-prompt field from combined model content. - -## Injection lifecycle - -When a turn is open, `inject()` stages the context in the loop outbox. The loop drains the outbox at a safe step boundary, preserving tool protocol adjacency: a context accepted during an assistant tool-call batch appears only after that batch's complete ordered results. Taking the outbox as a whole makes steering and injected context accepted for one boundary visible to the same following request. - -When no turn is open, `inject()` appends its `user/message` immediately and starts a session flush. It does not increment turn numbering, emit `turn/start` or `turn/end`, change agent status, or run the model. The synchronous API still returns before the asynchronous flush settles; `whenIdle()` and agent disposal include outstanding idle-injection flushes in their quiescence boundary. - -A failed idle flush has no legitimate turn or step coordinates. It is reported through logging or a persistence-owned error surface, not by inventing an `agent/error` payload for a nonexistent turn. The in-memory event remains accepted and a later flush may retry persistence. - -The session invariant therefore permits `user/message` between turns while continuing to require turn enclosure for execution events, steering, assistant output, tools, and package-added events by default. Persistence, recovery, resume, fork, and compaction code must treat a valid out-of-turn `user/message` as committed session history rather than an interrupted or discardable turn tail. - -## Extension and caller semantics - -`PromptDecision.content` continues to replace only the direct prompt. `PromptDecision.additionalContexts` and tool-result `additionalContexts` retain FIFO order and individual provenance, but no longer select placement. A waterfall listener that delegates with `next()` must preserve downstream prompt content and additional contexts unless it intentionally returns replacements. - -Caller-driven injection and hook-produced additional context deliberately have different admission ownership. A hook's additional contexts materialize only after that hook allows the prompt or tool result. A caller that invokes `inject(context)` and then `send(prompt)` has already committed context independently; if prompt admission later blocks the prompt, the injected context remains in history. Callers requiring all-or-nothing domain behavior must perform their own preparation before either operation or expose a domain-specific admission seam. - -Cross-session references follow the ordinary composition: the host prepares the snapshot, injects it with session-reference provenance, then sends or steers the readable direct prompt. The target log contains two simple messages, so later source mutation cannot change replay and transcript consumers do not need a prompt envelope. This supersedes the attachment mechanism in the [cross-session reference decision](../../implemented/feature/2026-07-21-cross-session-references.md) while retaining its snapshot and trust-boundary rules. - -This proposal preserves the caller-owned framing decision from [unwrapped injected content](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md), the one-item turn rule from [one send, one turn](../../implemented/simplification/2026-07-17-one-send-one-turn.md), and narrows the [turn-enclosure decision](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) so turns enclose execution rather than every session event. - -## Alternatives considered - -**Keep `SendOptions.contexts` as an atomic attachment.** This preserves all-or-nothing delivery when prompt admission blocks, but it keeps context inside inbox lifecycle state and requires every queue transition and observation event to carry it. The generic agent API should not encode a domain transaction that most callers can express as context injection followed by message delivery. - -**Keep a distinct `context/message` session event.** A separate event makes the out-of-turn exception narrower, but user-role model input would again have two event types with identical projection. `user/message.source` already carries the distinction needed by policy, transcript, and replay consumers. - -**Keep one-shot turns for idle injection.** This retains universal turn enclosure and a convenient flush boundary, but it makes turn counts and turn observers report work that never ran the model. Durability is an independent session concern and can be awaited without fabricating execution. - -**Keep `prompt-prefix` as an optional placement.** Prefix baking can make the context and request appear in one provider message, but it introduces a second representation of the direct prompt and spreads placement handling across admission, steering, logging, replay, and UI code. Producers that require textual framing may include it in their own context content. - -**Let hooks call `inject()` directly instead of returning additional contexts.** Direct injection would erase the extension point's admission ownership: a listener could append context before a downstream listener blocks the operation. Returning `additionalContexts` keeps the waterfall result authoritative while sharing the same post-admission outbox path. - -## Acceptance criteria - -- `SendOptions` and steering inbox records contain no attached contexts; `agent/queued` reports only the retained message and steering facts. -- `AdditionalContext` replaces `HookContext` across prompt interception, tool execution, hook bridges, guards, and context producers, with only `content` and `source`. -- Prompt-prefix placement, prompt envelopes, and `context/message` are absent from public types, durable events, projection, and UI replay. -- Idle `inject()` appends and flushes one sourced `user/message` without a turn or model call; `whenIdle()` and disposal await the flush. -- Active-turn injection and hook-produced contexts drain at safe boundaries after complete tool-result batches and before the request that consumes them. -- Blocked prompt admission opens no turn and appends neither the prompt nor hook-produced additional contexts; independently injected caller context remains. -- Unit, persistence/resume, invariant, ACP/TUI replay, and keyless assembled-application snapshots cover the new event order and durability semantics. - -## Risks - -- Allowing one surface event outside turns weakens a simple invariant and may expose hidden assumptions in persistence scanning, crash repair, forking, compaction, and session queries. -- Consecutive user-role messages replace one baked prompt message; provider adapters and cache behavior must accept and preserve that ordering. -- `inject()` followed by a blocked `send()` leaves context without its intended direct prompt unless the caller accepts the independent-commit contract. -- A synchronous injection API cannot return flush failure. Logging alone is less structured than `agent/error`, while adding a new persistence event solely for this case may create another unnecessary seam. -- Removing attachment, placement, metadata, envelopes, and a durable event type is a broad pre-release migration that must update every producer and consumer atomically. diff --git a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md b/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md deleted file mode 100644 index 1064e7a869..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md +++ /dev/null @@ -1,75 +0,0 @@ -# Agent Note: 将上下文注入与轮次执行分离 - -Status: proposed - -[English](2026-07-24-separate-context-injection-from-turn-execution.md) | 中文 - -## 问题 - -agent API 目前用三种相互重叠的方式表示面向模型的补充输入:调用方通过 `SendOptions.contexts` 附加 `HookContext[]`,拦截钩子和工具钩子返回 `additionalContexts`,插件则调用 `agent.inject()`。这些路径最终都会把上下文写入同一份模型历史,但各自携带不同的放置、元数据、准入、队列和轮次生命周期规则。 - -将上下文原子附加到收件箱消息后,agent loop(智能体循环)必须让上下文跟随提示词准入、steering(中途引导)转换、取消和终止丢弃的完整生命周期。`prompt-prefix` 放置方式又会把上下文与直接提示词合并为一个事件,因此 transcript(文本记录)消费方需要依赖模型不可见的封套,才能还原用户实际输入。这样一来,outbox 条目、会话投影和 UI 回放都必须处理本应由生产方负责的区分。 - -空闲状态下的 `inject()` 还暴露了另一处语义错位。注入并不请求模型执行,但当前实现仅为了满足轮次封闭不变量并获得持久性检查点,就会打开并关闭一个零步骤的 `injection` 轮次。于是,轮次有时表示「运行 agent loop」,有时却表示「不运行 agent,仅持久化上下文」。 - -`HookContext` 的名字也描述了生产方,而非该值的职责。它可能来自原生插件、hook bridge、提示词准入或工具后处理;其稳定含义只是带来源信息的额外模型上下文。 - -## 提案 - -将 `inject()` 设为调用方添加补充模型输入的唯一操作,并把轮次严格定义为一次模型循环执行。 - -移除 `SendOptions.contexts`。拥有上下文的调用方通过 `inject()` 交付上下文,再独立使用 `send()` 或 `steer()` 提交直接消息。将 `HookContext` 重命名为 `AdditionalContext`;这个共享结构只保留 `content` 和 `source`,移除放置方式与模型不可见元数据。 - -提示词和工具扩展点仍可返回 `additionalContexts`。这些值是扩展点的输出,而不是从调用方收件箱条目捕获的附件。提示词准入在 `run()` 打开轮次之前执行。提示词获准后,它与返回的额外上下文一同进入 outbox;提示词被阻止时,两者都不写入,也不打开轮次。工具产生的额外上下文则在对应工具结果之后进入同一个 outbox。 - -每项额外上下文都成为独立的 `user/message`,并由 `source` 记录来源。移除 `context/message`、prompt-prefix 放置方式、稳定请求分隔符和提示词封套。transcript 与 UI 消费方通过 `source` 区分直接用户消息和注入上下文,无需从合并后的模型内容中恢复隐藏的直接提示词字段。 - -## 注入生命周期 - -轮次打开时,`inject()` 将上下文暂存在 loop outbox 中。agent loop 会在安全的步骤边界排空 outbox,同时保持工具协议要求的相邻关系:在助手工具调用批次期间接纳的上下文,只能出现在该批次所有有序结果之后。系统整体取走 outbox,确保同一边界接纳的 steering 和注入上下文对后续同一次请求可见。 - -没有打开的轮次时,`inject()` 会立即追加对应的 `user/message` 并启动会话刷新。它不会增加轮次编号、发出 `turn/start` 或 `turn/end`、改变 agent 状态,也不会运行模型。同步 API 仍会在异步刷新完成前返回;`whenIdle()` 和 agent dispose(资源释放)会把尚未结束的空闲注入刷新纳入静止边界。 - -空闲刷新失败时不存在合法的轮次或步骤坐标。系统通过日志或持久化所属的错误接口报告该失败,而不是为不存在的轮次伪造 `agent/error` 载荷。内存中的事件仍已接纳,后续刷新可以重试持久化。 - -因此,会话不变量允许 `user/message` 位于两个轮次之间,同时继续要求执行事件、steering、助手输出、工具事件以及默认的包扩展事件均受轮次边界约束。持久化、恢复、resume、fork、压缩和查询逻辑必须把合法的轮次外 `user/message` 当作已提交会话历史,而不是中断轮次或可丢弃的日志尾部。 - -## 扩展点与调用方语义 - -`PromptDecision.content` 仍只替换直接提示词。`PromptDecision.additionalContexts` 和工具结果的 `additionalContexts` 保留 FIFO 顺序及各自来源,但不再选择放置方式。waterfall(瀑布式事件)监听器调用 `next()` 委托时,必须保留下游返回的提示词内容和额外上下文,除非它有意返回替代值。 - -调用方主动注入与钩子产生的额外上下文具有不同的准入归属。钩子的额外上下文只会在该钩子允许提示词或工具结果后落入日志。调用方执行 `inject(context)` 后再执行 `send(prompt)` 时,上下文已独立提交;后续提示词准入即使阻止该提示词,注入上下文仍保留在历史中。需要领域级全有或全无语义的调用方,必须在执行任一操作前自行完成准备,或提供领域专用的准入 seam。 - -跨会话引用使用普通组合方式:宿主先准备快照,以会话引用来源调用 `inject()`,再发送或 steer 可读的直接提示词。目标日志包含两条简单消息,因此来源会话后续变化不会改变回放,transcript 消费方也不需要提示词封套。本提案取代[跨会话引用决策](../../implemented/feature/2026-07-21-cross-session-references.md)中的附件机制,但保留其快照与信任边界规则。 - -本提案保留[移除注入内容封套](../../implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md)确立的调用方自主管理框架原则,以及[一次 send、一个轮次](../../implemented/simplification/2026-07-17-one-send-one-turn.md)确立的单条目轮次规则;同时收窄[轮次封闭决策](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md),使轮次约束执行过程,而不是约束所有会话事件。 - -## 曾考虑的替代方案 - -**保留 `SendOptions.contexts` 作为原子附件。** 提示词准入阻止消息时,这种方式能保留全有或全无交付,但也会让上下文继续成为收件箱生命周期状态的一部分,并迫使每次队列转换和观察事件携带它。大多数调用方都可以通过先注入上下文、再交付消息来表达需求,通用 agent API 不应内置领域事务。 - -**保留独立的 `context/message` 会话事件。** 独立事件可以缩小轮次外事件的例外范围,但面向模型的 user-role 输入会再次拥有两个投影完全相同的事件类型。`user/message.source` 已能为策略、transcript 和回放消费方提供所需区分。 - -**为空闲注入保留一次性轮次。** 这种方式能保留通用轮次封闭和方便的刷新边界,却会让轮次计数与轮次观察方报告从未运行模型的工作。持久性是独立的会话关注点,无需伪造执行即可等待。 - -**保留 `prompt-prefix` 可选放置方式。** 前缀烘焙可以让上下文和请求位于同一条提供方消息中,但它会引入直接提示词的第二种表示,并把放置处理扩散到准入、steering、日志、回放和 UI 代码。需要文本框架的生产方可以直接把它写入自身上下文内容。 - -**让钩子直接调用 `inject()`,而不是返回额外上下文。** 直接注入会破坏扩展点的准入归属:下游监听器阻止操作之前,上游监听器就可能已经追加上下文。返回 `additionalContexts` 能维持 waterfall 结果的最终权威性,同时复用准入后的 outbox 路径。 - -## 验收标准 - -- `SendOptions` 与 steering 收件箱记录不再包含附加上下文;`agent/queued` 只报告保留的消息和 steering 事实。 -- `AdditionalContext` 在提示词拦截、工具执行、hook bridge、guard 和上下文生产方中取代 `HookContext`,且只包含 `content` 与 `source`。 -- 公共类型、持久事件、投影和 UI 回放中均不存在 prompt-prefix 放置方式、提示词封套与 `context/message`。 -- 空闲 `inject()` 在不产生轮次或模型调用的情况下,追加并刷新一条带来源的 `user/message`;`whenIdle()` 和 dispose 会等待该刷新。 -- 活跃轮次注入和钩子产生的上下文会在完整工具结果批次之后的安全边界排空,并在消费它们的请求之前进入日志。 -- 被提示词准入阻止的消息不会打开轮次,也不会追加提示词或钩子产生的额外上下文;调用方此前独立注入的上下文仍保留。 -- 单元测试、持久化与 resume 测试、不变量测试、ACP/TUI 回放测试,以及无需密钥的组装应用快照覆盖新的事件顺序和持久性语义。 - -## 风险 - -- 允许一个表层事件位于轮次之外,会削弱一条简单不变量,并可能暴露持久化扫描、崩溃恢复、fork、压缩和会话查询中的隐含假设。 -- 两条连续的 user-role 消息会取代一条烘焙后的提示词消息;提供方适配器和缓存行为必须接受并保留这一顺序。 -- 如果调用方不能接受独立提交契约,`inject()` 后跟一个被阻止的 `send()` 会留下缺少预期直接提示词的上下文。 -- 同步注入 API 无法返回刷新失败。只记录日志的结构化程度低于 `agent/error`,但仅为此场景增加新的持久化事件也可能产生另一个不必要的 seam。 -- 移除附件、放置方式、元数据、封套和一种持久事件类型,是一次影响面较广的预发布迁移,必须原子更新所有生产方和消费方。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml index 19915e8383..b14e969a84 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml @@ -1,6 +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 +# pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md 2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3 2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index 319cddc46d..e4df7e1fe7 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. --- # Archive DeepSeek Harness Agent Notes @@ -11,6 +11,10 @@ Reduce the active decision corpus without erasing history that can still guide w Read [the Agent Note contract](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. +## Check supersession when adding a note + +Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note contract's consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. + ## Classify by future value Apply these lifecycle-specific outcomes: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..c69503be23 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,59 @@ +version: 2 + +updates: + - package-ecosystem: "npm" + directory: "/" + exclude-paths: + # Vendored Cordis sources follow vendor/README.md instead of registry updates. + - "vendor/**" + # This independently locked pnpm workspace has its own update entry below. + - "native/landlock-run/**" + schedule: + interval: "weekly" + day: "friday" + time: "00:01" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "npm" + directory: "/native/landlock-run" + schedule: + interval: "weekly" + day: "friday" + time: "00:01" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "uv" + directory: "/python/sdk" + schedule: + interval: "weekly" + day: "friday" + time: "00:01" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "friday" + time: "00:01" + timezone: "Asia/Shanghai" + cooldown: + default-days: 30 + labels: + - "cleanup" + - "area/infra" diff --git a/.jscpd.json b/.jscpd.json index 89ec21f0fd..b42c2d17a0 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -2,8 +2,8 @@ "minTokens": 60, "minLines": 6, "mode": "mild", - "format": ["typescript"], - "pattern": "**/*.ts", + "format": ["typescript", "tsx"], + "pattern": "**/*.{ts,tsx}", "ignore": ["**/tests/**", "**/tsdown.config.ts"], "ignorePattern": [ "(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/" diff --git a/AGENTS.md b/AGENTS.md index dabf2b9945..c987801c17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// core/ product API spine: session, system-prompt, tools, agent, agent-loop llm/ LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools + subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool diff --git a/README.i18n.yaml b/README.i18n.yaml index c6390e407a..7584d4f293 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -1,6 +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 -README.md: 8b3a46081503ac9a29cc791cc302066e33e0f995 -README.zh.md: c73e2c70119d5b82d4629041a7a16848f7acbe92 +# pnpm run verify-translation-pairing --write README.md +README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3 +README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f diff --git a/README.md b/README.md index 8b3a460815..f9f7294b42 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,22 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. -The installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. +The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. ## Use DeepSeek Harness ### Web UI -For the recommended local interface, build the frontend after installation and after each update, then start the Web UI: +For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink): ```sh -pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web dsh web ``` diff --git a/README.zh.md b/README.zh.md index c73e2c7011..88cbf8522d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,16 +16,22 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m 安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 -安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 +安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 ## 使用 DeepSeek Harness ### Web UI -推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI: +推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析): ```sh -pnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web +dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)") +while [ -L "$dsh_bin" ]; do + link=$(readlink "$dsh_bin") + case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac +done +dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P) +pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web dsh web ``` diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 65f20cf59e..5397c08746 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -84,6 +84,10 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' +# Session store root. AppCLIEntry resolves the engineering default to a +# global dir under the Harness home ($DSH_HOME, else ~/.dsh): sessions live +# in one place across every cwd, not a project-local ./.sessions. The +# persistenceRoot profile key (user config) still overrides this per field. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: @@ -105,6 +109,10 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 44920d6baf..cd3edd2dd1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 29e20b87b8..344c66e2b3 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -117,10 +117,11 @@ export class AppCLIEntry { } /** - * Compose the patch set from the three non-yml config sources: profile - * json (user config), CLI flags, and the resolved frontend dist. Patches - * replace a row's config wholesale, so each patched row's yml static - * values are re-read here (bypass parse) and merged under the overrides. + * Compose the patch set from the non-yml config sources: computed + * engineering defaults (the global session root), profile json (user + * config, overriding those defaults), CLI flags, and the resolved frontend + * dist. Patches replace a row's config wholesale, so each patched row's yml + * static values are re-read here (bypass parse) and merged under the overrides. */ private composePatches(): void { const rows = this.parseYmlRows() @@ -131,6 +132,12 @@ export class AppCLIEntry { overrides.set(entryId, bag) } + // Source 0: computed engineering defaults. The session store defaults to + // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history + // is shared across every cwd, not a project-local ./.sessions. The profile + // (Source 1) overwrites this same field via last-write-wins in put(). + put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) + // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 4283668189..a87812ce00 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh/tui */ +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -62,6 +63,7 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` // flag, so the resumed process rehydrates through this same intake. The host // is offered only when Node exposes `process.execve` and knows its own entry. diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts new file mode 100644 index 0000000000..07f267df56 --- /dev/null +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -0,0 +1,130 @@ +// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real +// model through inspect, mount, and unmount; replay pins the same shipped Web +// composition, durable calls, generic rows, highlighted Plugin source, and +// conversation accessibility tree. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const CORDIS_TOOLS = ['cordis_inspect', 'cordis_mount', 'cordis_unmount'] as const +const MOUNT_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }' +const PROMPT = 'Use only Cordis tools. First call cordis_inspect with what "temporary". ' + + `Then call cordis_mount with this exact code: ${JSON.stringify(MOUNT_CODE)}. ` + + 'Read its returned id and call cordis_unmount with that exact id. ' + + 'After all three calls succeed, reply exactly CORDIS_UI_DONE and stop.' + +function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { + const turnEnd = events.findLast( + (event): event is Extract => event.type === 'turn/end', + ) + const reason = turnEnd?.data.reason + const reasonSummary = reason?.kind === 'error' + ? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status } + : { kind: reason?.kind } + expect(reasonSummary).toEqual({ kind: 'completed' }) + + const calls = events.filter( + (event): event is Extract => event.type === 'tool/call', + ) + expect(calls.map(event => event.data.name)).toEqual(CORDIS_TOOLS) + + const callIds = new Set(calls.map(event => String(event.data.callId))) + const results = events.filter( + (event): event is Extract => + event.type === 'tool/result' && callIds.has(String(event.data.callId)), + ) + expect(results).toHaveLength(CORDIS_TOOLS.length) + expect(results.every(event => !event.data.isError)).toBe(true) +} + +describe('web e2e: Cordis tools use the generic row variants', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + cordisTools: true, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + assertCompleteCordisLifecycle(sessionEvents) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('the durable log carries one complete Cordis lifecycle', () => { + assertCompleteCordisLifecycle(sessionEvents) + }) + + it.skipIf(MODE === 'record')('renders Cordis lifecycle titles over the generic row mechanics', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows')) + await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + + const inspectRow = page.locator('[data-tool="cordis_inspect"]').filter({ hasText: 'Inspect' }).first() + await inspectRow.waitFor({ timeout: 10_000 }) + + const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first() + await mountRow.waitFor({ timeout: 10_000 }) + await mountRow.locator('button[aria-expanded]').click() + await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 }) + .toContain(MOUNT_CODE) + + const unmountRow = page.locator('[data-tool="cordis_unmount"]').filter({ hasText: 'Unmount temporary Plugin' }).first() + await unmountRow.waitFor({ timeout: 10_000 }) + await expect.poll(() => unmountRow.textContent()).toContain('dyn-') + await expect(unmountRow.getAttribute('data-state')).resolves.toBe('ok') + }) + + it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria')) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean: no page errors or reconnect churn', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 46f6af7b86..6ecdb683b3 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -28,7 +28,10 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.' +// The options carry long descriptions on purpose: the squeeze assertion below +// needs option copy that WRAPS, which is the only shape that reproduces a +// collapsed row painting its copy outside its own box. +const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -79,6 +82,48 @@ describe('web e2e: resident question composer round trip', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) } + // Squeezed card: the option rows are the capped card's scroll content, so + // shrinking the seat must push overflow into the option list, never + // collapse a row below the height its own copy needs — a collapsed row + // paints its centered copy outside the row box, over the title and the + // neighbouring rows. Measured on the live composer at seat heights that + // force the cap, then restored for the answer gesture below. Replay only: + // record mode must reach the recording write below, not abort on layout. + if (MODE !== 'record') { + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + for (const height of [520, 440, 380]) { + await page.setViewportSize({ width: 900, height }) + const squeeze = await composer.evaluate((card) => { + // Role/ARIA selectors, not the CSS-module class names: the built + // client hashes those. + const rows = [...card.querySelectorAll( + '[role="radio"], [role="checkbox"], [aria-expanded]', + )] + const spill = rows.map(row => Math.max(...[...row.children].map((child) => { + const box = row.getBoundingClientRect() + const inner = child.getBoundingClientRect() + return Math.max(box.top - inner.top, inner.bottom - box.bottom) + }))) + const list = rows[0]?.parentElement ?? null + return { + rows: rows.length, + spill: Math.max(...spill), + // Wrapped copy is the shape that overflows a collapsed row, and a + // scrolling list proves the seat is genuinely capped. Without both, + // the spill assertion would hold vacuously. + wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, + scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + } + }) + expect(squeeze.rows).toBeGreaterThan(0) + expect(squeeze.wrappedRows).toBeGreaterThan(0) + expect(squeeze.scrolls).toBe(true) + // Sub-pixel tolerance: every row's copy stays inside its border box. + expect(squeeze.spill).toBeLessThan(0.6) + } + await page.setViewportSize(original) + } + await composer.getByRole('radio', { name: 'Blue' }).click() // Submit: Enter on the focused option (the composer's documented submit). await composer.getByRole('radio', { name: 'Blue' }).press('Enter') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 44b9786928..f722ead61b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -40,6 +40,7 @@ import SessionStore, { type SessionHeader, } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' @@ -116,6 +117,12 @@ export interface LaunchOptions { * insertion is needed. */ toolsMode?: 'native' | 'code' | 'both' + /** + * Insert the opt-in self-referential Cordis tools into the shipped tree. + * Record and replay use the same tool surface, so captured request headers + * remain reconstructable without making the tools a product default. + */ + cordisTools?: boolean } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -169,6 +176,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise el.getAttribute('aria-haspopup') === 'menu')!) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl new file mode 100644 index 0000000000..ab6c86089d --- /dev/null +++ b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl @@ -0,0 +1,56 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785157564667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785157564667,"data":{"turn":1,"step":1,"index":0,"dt":[115,31,3,0,1,0,1,21,1,0,0,0,1,23,0,1,0,0,0,24,2,1,0,0,0,28,2,0,0,0,1,23,2,22,2,1,25,2,0,0,25,27,1,1,0,0,28,2,0,0,0,0,32,1,31,1,0,0,0,9,29,3,0,0,0,1,23,1,0,0,0,27,4,0,0,0,23,2,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Call"," `","cord","is","_in","spect","`"," with"," `","what",":"," \"","t","emporary","\"`\n","2","."," Call"," `","cord","is","_m","ount","`"," with"," the"," exact"," code"," provided","\n","3","."," Read"," the"," returned"," id"," and"," call"," `","cord","is","_un","mount","`"," with"," that"," exact"," id","\n","4","."," Reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","\n\n","Let"," me"," start"," with"," step"," ","1","."]}} +{"type":"assistant/chunk","seq":87,"time":1785157565360,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":88,"time0":1785157565360,"data":{"turn":1,"step":1,"index":1,"dt":[15,2,0,0,25,2,0,0,27,1],"id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","args":["","{","\"","what","\"",": ","\"","t","emporary","\"","}"]}} +{"type":"assistant/chunk","seq":99,"time":1785157565490,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."}}}} +{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}} +{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}} +{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}} +{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"} +{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":109,"time0":1785157566525,"data":{"turn":1,"step":2,"index":0,"dt":[105,30,2,0,0,24,2,0,0,27,2,1,0,25,0,0,0,1,0,39,1],"texts":["Good",","," no"," temporary"," plugins"," running","."," Now"," step"," ","2",":"," call"," cord","is","_m","ount"," with"," the"," exact"," code","."]}} +{"type":"assistant/chunk","seq":131,"time":1785157566845,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":132,"time0":1785157566845,"data":{"turn":1,"step":2,"index":1,"dt":[42,4,1,0,0,15,2,0,0,0,16,0,0,0,0,1,23,2,0,0,25,7,18],"id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","args":["","{","\"","code","\"",": ","\"","return"," {"," name",":"," \\\"","sn","apshot","-no","op","\\\","," apply","(ctx",")"," {}"," }","\"","}"]}} +{"type":"assistant/chunk","seq":156,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."}}}} +{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}} +{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}} +{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"} +{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":166,"time0":1785157567836,"data":{"turn":1,"step":3,"index":0,"dt":[83,25,2,0,0,26,1,1,0,0,0,41,2,0,0,0,0,8,2,0,0,0],"texts":["The"," id"," is"," \"","d","yn","-","1","\"."," Now"," step"," ","3",":"," call"," cord","is","_un","mount"," with"," that"," id","."]}} +{"type":"assistant/chunk","seq":189,"time":1785157568117,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":190,"time0":1785157568118,"data":{"turn":1,"step":3,"index":1,"dt":[17,2,1,24,2,0,1,25,0,0,0,29],"id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","args":["","{","\"","id","\"",": ","\"","d","yn","-","1","\"","}"]}} +{"type":"assistant/chunk","seq":203,"time":1785157568278,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."}}}} +{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"} +{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":213,"time0":1785157569186,"data":{"turn":1,"step":4,"index":0,"dt":[165,26,26,1,0,27,9,17,2,1,0,0,0,44,1,0,0,1,0,6,3],"texts":["All"," three"," calls"," succeeded","."," I"," should"," now"," reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":235,"time":1785157569515,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":236,"time0":1785157569515,"data":{"turn":1,"step":4,"index":1,"dt":[0,0,35,1,0,0],"texts":["C","ORD","IS","_","UI","_D","ONE"]}} +{"type":"assistant/chunk","seq":243,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}} +{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md new file mode 100644 index 0000000000..e2a1275f6c --- /dev/null +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -0,0 +1,48 @@ +- banner: + - navigation "Session hierarchy": + - button "Use only Cordis tools. First" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "▸ 上下文注入" +- button "Think The user wants me to:": + - img + - text: "Think The user wants me to:" +- button: + - img +- text: Inspect temporary +- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."': + - img + - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." +- button [expanded]: + - img +- text: Mount temporary Plugin typescript +- button "复制" +- code: "return { name: \"snapshot-noop\", apply(ctx) {} }" +- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."': + - img + - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." +- button: + - img +- text: Unmount temporary Plugin dyn-1 +- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.": + - img + - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. +- paragraph: CORDIS_UI_DONE +- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps +- textbox "Message the agent" +- button "Add attachment": + - img +- combobox "Access mode": + - option "Read-only" [selected] + - option "Read-write" +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 5856bcca60..94179416a4 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -6,18 +6,24 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop. -- button "Think The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that.": +- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop." +- button "复制": - img - - text: Think The user wants me to use the ask_user_question tool to ask a specific question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". Let me do exactly that. +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": + - img + - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. - button: - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}" -- button "Think The user answered \"Blue\". I need to reply with the single word DONE and stop.": +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}" +- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.": - img - - text: Think The user answered "Blue". I need to reply with the single word DONE and stop. + - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- text: cache hit 99% · 15,978 tokens · 1 turns · 2 steps +- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 43cc228253..a98f2a92f2 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,31 +1,31 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785001700711,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1785001700724,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785001700725,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785001700727,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785001700783,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785001700784,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785001701372,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785001701373,"data":{"turn":1,"step":1,"index":0,"dt":[117,23,0,0,0,1,26,1,0,0,0,0,25,0,0,0,27,1,24,1,0,0,0,27,0,0,0,0,1,34,0,0,0,0,1,17,0,0,0,1,0,27,1,0,0,28,0,0,22,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," a"," specific"," question"," with"," id"," \"","color","\","," question"," \"","Which"," color"," do"," you"," prefer","?\","," header"," \"","Pick"," one","\","," and"," options"," labeled"," \"","Blue","\""," and"," \"","Green","\"."," Let"," me"," do"," exactly"," that","."]}} -{"type":"assistant/chunk","seq":57,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":58,"time0":1785001701858,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,24,1,0,0,28,0,0,0,0,1,24,0,0,1,0,0,26,0,0,0,0,0,26,0,1,0,0,0,25,0,0,0,0,3,23,1,0,0,0,0,26,1,26],"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\"},"," {\"","label","\":"," \"","Green","\"","}]","}]","}"]}} -{"type":"assistant/chunk","seq":107,"time":1785001702154,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":108,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}}} -{"type":"assistant/chunk","seq":109,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}}}} -{"type":"assistant/chunk","seq":110,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":111,"time":1785001702159,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."},{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[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":1785001702160,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}} -{"type":"tool/result","seq":113,"time":1785001702566,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1785001702568,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":115,"time":1785001702569,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":116,"time":1785001702948,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":117,"time0":1785001702949,"data":{"turn":1,"step":2,"index":0,"dt":[84,26,1,0,0,0,0,29,0,0,22,0,1,0,0,0,27,1],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} -{"type":"assistant/chunk","seq":136,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":137,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":138,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":139,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."}}}} -{"type":"assistant/chunk","seq":140,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":141,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":142,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":143,"time":1785001703141,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}},"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],"surfaceOp":"append"} -{"type":"step/end","seq":144,"time":1785001703142,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":145,"time":1785001703142,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} +{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":137,"time0":1785150170606,"data":{"turn":1,"step":2,"index":0,"dt":[111,29,0,0,0,1,34,0,0,17,1,29,0,0,0,0,1,26],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"assistant/chunk","seq":156,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":157,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":158,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":159,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."}}}} +{"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 3368b373a0..934ec244d5 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -10,10 +10,10 @@ - img - radiogroup: - radio "Blue": - - text: 1 Blue + - text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - img - radio "Green": - - text: 2 Green + - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - img - button "其他,请填写自定义答案": - img diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 8041df307d..5521d984da 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -45,7 +45,6 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise { await page.getByRole('button', { name: 'Choose workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 0166c6a9d9..e1fe183ef1 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -134,10 +134,9 @@ function setComposerText(composer: HTMLElement, value: string): void { expect((composer as HTMLTextAreaElement).value).toBe(value) } -/** Drive the picker's create flow: chip → Create workspace → name dialog. */ +/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */ async function createWorkspaceViaPicker(name: string): Promise { fireEvent.click(workspaceChip()) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 98a2338064..fd18d89087 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,9 +30,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType + let pickedDirectory: string | null = null beforeAll(async () => { scaffold = await launchWebScaffold({}) + scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: true, value: { path: pickedDirectory } }, + }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -55,8 +60,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create')) const createByName = async (name: string): Promise => { await page.getByRole('button', { name: 'Create workspace' }).click() - // The pick menu's Create workspace submenu opens on hover/focus. - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -134,14 +137,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await useFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -197,13 +200,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await reuseFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) expect(reregistered?.id).toBeDefined() expect(reregistered?.id).not.toBe(workspace.id) @@ -269,13 +272,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) + pickedDirectory = oldPath await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) - await adopt.getByLabel('Existing folder path').fill(oldPath) - await adopt.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(oldPath), + { timeout: 10_000 }, + ).not.toBeUndefined() const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') @@ -288,7 +291,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const create = page.getByRole('dialog', { name: 'Create a new workspace' }) await create.getByLabel('New workspace name').fill(title) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8bed531598..559e72618b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -32,7 +32,8 @@ "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts", - "tests/code-mode-round.e2e.ts" + "tests/code-mode-round.e2e.ts", + "tests/cordis-tool-round.e2e.ts" ], "references": [ { diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 5f1e43eb17..b9d0d6cb33 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -15,18 +15,21 @@ sequenceDiagram participant LLM as ctx.llm participant Tools as ctx.tools participant Session - participant Persistence participant SDK as UI or SDK listener User->>Agent: followup(content) Agent-->>SDK: agent/inbox/enqueue Agent->>Driver: queued work wakes driver Driver-->>SDK: agent/status running - Driver->>Session: turn/start + Note over Agent,Driver: next-step acceptance window opens Driver->>Hooks: agent/prompt-submit waterfall Hooks-->>Driver: authoritative allow, block, or add context - Driver->>Session: user/message or rejected turn/end + alt prompt blocked or admission failed + Driver-->>Driver: append context-only batch or keep steering boundary pending + else prompt allowed + Driver->>Session: turn/start + Driver->>Session: user/message Driver->>Prompt: system-prompt/assemble waterfall - Driver-->>Driver: agent/pre-step serial checkpoint + Driver-->>Driver: agent/step serial checkpoint Driver->>Session: step/start Driver->>LLM: agent/request waterfall, then llm/stream waterfall LLM-->>Driver: StreamChunk* @@ -35,9 +38,8 @@ sequenceDiagram alt final adapter or terminal in-band request failure Driver->>Session: step/end Driver->>Hooks: agent/request-error waterfall - Hooks-->>Driver: retry in a new step or preserve the original error + Hooks-->>Driver: return retry action or preserve the original error else model request succeeded - Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message Driver->>Tools: classify pending call by executionMode loop barriers and bounded rolling pool, reclassify before start @@ -52,19 +54,18 @@ sequenceDiagram end end Driver->>Session: post-tool context and steering (no prompt-submit) - Driver->>Hooks: agent/post-step serial checkpoint Driver->>Session: step/end - Driver->>Hooks: agent/turn-continuation waterfall - Driver->>Hooks: agent/turn-stop serial terminal checkpoint + Driver->>Hooks: agent/turn-stopping serial terminal checkpoint end + Note over Agent,Driver: next-step acceptance window closes Driver->>Session: turn/end - Driver->>Persistence: session/flush parallel checkpoint + end Driver-->>SDK: agent/status idle ``` 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. 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. +`dsh-compact-basic` uses `agent/step` for pressure before request derivation 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 failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. The returned `agent/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e641d6ea2f..4d19163fc4 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -1,6 +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 -architecture.md: 34e56dd955e6ac5ad8fad236bdf7ae9cfc810a83 -architecture.zh.md: ea105b869eba799d00dfaa134ed34d14c7505fc8 +# pnpm run verify-translation-pairing --write docs/architecture.md +architecture.md: 56d391101aaa44908fc65201bd364452a2eaed27 +architecture.zh.md: ab76b0a256c4f23de360bbf08c042725816278bd diff --git a/docs/architecture.md b/docs/architecture.md index 34e56dd955..56d391101a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ English | [中文](architecture.zh.md) ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed services, typed events, and disposable registrations. +Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; capabilities remain plugins. @@ -14,11 +14,11 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | ctx key | Package | Role | |---|---|---| -| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration and shared layer storage (library) | +| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registrations and shared layer storage (library) | | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | -| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | +| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, and process-local initiator scope | +| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -26,46 +26,45 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | ctx key | Package family | Role | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | -| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `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.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry | | `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`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning | +| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider | -| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider | +| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event -Events form the service extension API; see the [catalog](cordis-catalog/events.md) and [producer/consumer map](event-producer-consumer.md). +Events are the service extension API ([catalog](cordis-catalog/events.md), [producer/consumer map](event-producer-consumer.md)). ### Event Domains -- **Session events** are durable facts appended to the log and emitted through `session/event`. -- **Agent events** carry the live `Agent` for status, prompt admission, request shaping, validation, and continuation. -- **Capability events** let owning seams attach policy and adapters without importing the loop. +- **Session events** are durable log facts emitted through `session/event`. +- **Agent events** carry live `Agent` for status, prompt admission, request shaping, validation, and continuation. +- **Capability events** let owning seams attach policy and adapters without a loop import. ### Interception Semantics -Waterfall events behave like around-middleware: a listener delegates by calling `next()`; returning without it vetoes or takes over. Full rule: [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics). +Waterfalls are around-middleware: listeners delegate with `next()`; returning without it vetoes or takes over ([semantics](cordis-primer.md#cordis-waterfall-semantics)). ## Default Loop Lifecycle -The loop runs through plugin services and events. - -A **session** is append-only. Each ordinary **turn** claims one queued message; injection claims none. Successors await the preceding checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A **step** is one model request plus tools; quotes in the [sequence below](agent-lifecycle.md) mark durable events. +A **session** is append-only. An ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits its predecessor'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 or plugins stop it; a **step** is one model request plus tools. Quotes in the [sequence below](agent-lifecycle.md) mark durable events. Creation without an id mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. @@ -78,96 +77,95 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for a queued message - emit agent/status(running) - TURN: - 'turn/start' - claimed message + contexts -> agent/prompt-submit - allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts - blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) + claim message -> emit agent/status(running) if starting an interval + open the next-step acceptance window + -> agent/prompt-submit + blocked or failed prompt -> close the window without opening a turn + append a context-only caller batch immediately + keep steering and context staged beside it pending for a later admitted turn + allowed prompt: + 'turn/start' + append prompt + additional contexts as separate 'user/message' events STEP loop: - drain steering with the same prefix/separate context placement (no prompt-submit) + agent/step + drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tool schemas - agent/session-prefix (first step) - agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) - on final adapter-path or terminal in-band failure: - 'step/end' - 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' - agent/step-result - 'assistant/message' (transformed content or empty success anchor after step-result rejection) - schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' - append accepted tool-batch context after all recorded results, then steering - agent/post-step -> checkpoint complete response/results - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step - 'turn/end' - checkpoint persistence and notify idle/running status + agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + 'assistant/chunk' + 'assistant/message' + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' + drain accepted tool context and steering + 'step/end' + continue for tools or steering unless a result concluded the turn + otherwise agent/turn-stopping -> drain -> continue only for steering + close the next-step acceptance window + 'turn/end' -> agent/settled + start the next waking queued message, or emit agent/status(idle) + +idle inject: + append 'user/message' + do not open a turn or run the model ``` -Steps assemble ordered prompt sections, tool schemas, and variables; unknown references fail turns. `dsh-system-prompt` owns identity and persona; the loop supplies `model` and `cwd` ([ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Async `inject()` and post-tool `additionalContexts` settle after results; steering drains before `agent/post-step`. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush and discards later steering, not queued prompts. +Admission-time and active-turn `inject()` stage for the next step; post-tool `additionalContexts` settles after results. Steering shares that staging boundary and requests another step. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. -Pruning precedes summaries; overflow retries require durable progress. Bounded 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)). +Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). ### Failure Boundaries -Adapter failures close the step before `agent/request-error` with exact `Error`, `LlmFailure`, and history. Retries open steps; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit nothing. +Adapter failures close their step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and signal. A handled failure closes its turn and opens a retry turn from durable history without an idle notification; exhaustion leaves terminal `turn/end`. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`. Cancellation and disposal beat recovery; the turn signal also cancels asynchronous model-capability preparation before any request header is committed, and undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal retires before `turn/end`. Effective `cancel()` emits its cause, clears queues, and aborts; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). -Session events are turn-enclosed; reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures use `agent/error`. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Turn and step events are turn-enclosed; idle injected `user/message` events may sit between turns. Reload closes an interrupted tail with a synthetic turn end. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). ### Agent Handles -`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown. +`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins use full `send()` options or `followup()`, `steer()`, and `inject()` presets; `cancel()` and `whenIdle()` control lifecycle. One awaited disposer coordinates teardown ownership. ### Agent Scope -Each agent owns a scoped `agent.ctx` over global tool, prompt, and command storage ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)); scoped listeners filter and contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication; typed resolvers derive carrier checks from `Events` and `scopeTarget` ([gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State ### Session Log -The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence share that stream. +The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream. -**Model-visible ⟺ logged**: `step/start` messages plus the header's session prefix and folded `request/header` reconstruct every request; `dsh-agent-loop/invariant` asserts this through `ctx.invariants` ([decision](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern; backends buffer synchronous `session/event` notifications. Checkpoints drain before adapter dispatch, recorded top-level tool calls before tool dispatch, complete response/result batches at `agent/post-step`, and final turn ends. `SessionPersistence` stores `SessionEvent` plus `SessionHeader` metadata; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). +Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede adapter dispatch, top-level tool dispatch, and the next request's `agent/step`. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` adds plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content -Messages use typed blocks from merge-extensible `ContentBlockMap`; the same pattern types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). +Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report facts and `agent/request-error` owns recovery. The loop logs chunks and successful provenance/replay state. Remote adapters use per-read idle watchdogs. Replay state crosses routes only when they share an adapter instance ([contract](core-data-structures/llm-streaming.md)). +Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts, and a handling `agent/request-error` plugin returns a retry action. The loop logs chunks, successful provenance, and replay state. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter instance ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition ### Capability Pattern -A swappable capability usually splits into **interface / implementation / consumer**: service/events, a backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. +A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family. -Exceptions combine layers: LLM interface/consumer; filesystem policy; web registries; named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). +Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)). -`dsh-workspace-context` composes baselines on `agent/session-prefix` and appends `ctx.fs`-discovered nested changes on `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. +`dsh-workspace-context` injects baseline at the first `agent/step` and appends `ctx.fs`-discovered changes through `tools/post-execute`; its [decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) records isolation. `dsh-paths` owns shared paths. ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own TUI, CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies a default only without explicit config ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own TUI, CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes @@ -175,28 +173,21 @@ New behavior attaches to a documented extension point; a loop change updates thi | Goal | Mechanism | |---|---| -| Add a model provider | register an adapter on `ctx.llm` | -| Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend | -| Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | -| Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | +| Add a model provider | register its adapter on `ctx.llm` | +| Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly | +| Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` | +| Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` | +| Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | -| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | -| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stop` is the serial terminal stop | -| Add a session-stable prefix outside history | compose `agent/session-prefix`; the request header logs it | -| Add UI or editor integration | drive `ctx.agents` and render from `session/event`; terminal-only overlays use `ctx.tui` | -| Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Add asynchronous session-title generation | register the sole provider on `ctx.sessionTitle` | +| Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | +| Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | +| Add model-facing context | call `agent.inject()` to append a sourced `user/message` without a turn | +| Add UI or editor integration | drive `ctx.agents`, render from `session/event`; terminal-only overlays use `ctx.tui` | +| Add durable session state | extend `SessionEventMap`; render and replay from the log | +| Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | -| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| Scope a registration to one agent | use that agent's `agent.ctx` (see Agent Scope) | +| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) | -The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). - -## Quick Reference -- Domain terms in the [glossary](glossary.md) -- Type definitions in [core-data-structures/](core-data-structures/core.md) -- Exact signatures in the [event](cordis-catalog/events.md) and [service](cordis-catalog/services.md) catalogs -- package contracts in the [package map](../packages/README.md) -- [Agent Notes](../.agents/notes/README.md) +The [extension cookbook](cookbook/extension-cookbook.md) has plugin skeletons and the feature-to-seam map; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ea105b869e..ab76b0a256 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -6,7 +6,7 @@ ## 概览 -每个 harness 都是一个 [Cordis](cordis-primer.md) 上下文,由各包(package)贡献服务、类型化事件和可释放的注册项。 +每个 harness 都是 [Cordis](cordis-primer.md) 上下文;各包(package)贡献服务、类型化事件和可释放的注册项。 `packages/core/` 汇集默认的 agent(智能体)流程;各项功能仍以插件形式存在。 @@ -14,11 +14,11 @@ | ctx 键 | 包 | 职责 | |---|---|---| -| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册与共享层存储(库) | +| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册项与共享层存储(库) | | `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 | -| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和提示词变量 | +| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和变量 | | `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | 活跃 agent、委托创建、`agent/*` 事件和进程内发起方作用域 | +| `ctx.agents` | `dsh-agent` | 活跃 agent、委托创建、`agent/*` 事件、进程内发起方作用域 | | `ctx.agentLoop` | `dsh-agent-loop` | 实体 `Agent` 驱动器 | ### 功能服务 @@ -26,48 +26,47 @@ | ctx 键 | 包族 | 职责 | |---|---|---| | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | -| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | +| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 执行模型编写的程序 | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | 文件系统提供方原语和策略事件 | | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | 语义导航注册表 | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 | | `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 | -| `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction);可选的无模型结果裁剪 | +| `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制工具 | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 -事件构成服务的扩展 API;参见[事件目录](cordis-catalog/events.md)和[生产方与消费方映射](event-producer-consumer.md)。 +事件就是服务的扩展 API([目录](cordis-catalog/events.md)、[生产方与消费方映射](event-producer-consumer.md))。 ### 事件域 -- **会话事件**是追加到日志并通过 `session/event` 发出的持久事实。 +- **会话事件**是通过 `session/event` 发出的持久日志事实。 - **Agent 事件**携带活跃 `Agent`,用于状态、提示词准入、请求塑形、验证和续跑。 - **功能事件**让所属服务边界无需导入循环即可附加策略和适配器。 ### 拦截语义 -waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 `next()` 即表示委托,直接返回而不调用它则会否决或接管。完整规则见 [Cordis waterfall 语义](cordis-primer.md#cordis-waterfall-semantics)。 +waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委托;不调用它而直接返回会否决或接管([语义](cordis-primer.md#cordis-waterfall-semantics))。 ## 默认循环生命周期 -循环通过插件服务和事件运行。 +**会话**采用仅追加方式。普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型或插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。[下文时序](agent-lifecycle.md)中的引号标记持久事件。 -**会话**采用仅追加方式。每个普通**轮次**领取一条已排队的消息;注入不领取消息。后续轮次会等待前一个检查点,但可以与前一轮次共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。一个**步骤**包含一次模型请求及其工具;在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 - -未提供 id 时,创建流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 +创建时若未提供 id,流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 ### 轮次流程 @@ -78,96 +77,95 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for a queued message - emit agent/status(running) - TURN: - 'turn/start' - claimed message + contexts -> agent/prompt-submit - allowed prompt -> 'user/message' with prompt-prefix context baked in; append separate contexts - blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) + claim message -> emit agent/status(running) if starting an interval + open the next-step acceptance window + -> agent/prompt-submit + blocked or failed prompt -> close the window without opening a turn + append a context-only caller batch immediately + keep steering and context staged beside it pending for a later admitted turn + allowed prompt: + 'turn/start' + append prompt + additional contexts as separate 'user/message' events STEP loop: - drain steering with the same prefix/separate context placement (no prompt-submit) + agent/step + drain injected context and steering (steering bypasses prompt-submit) assemble system prompt and tool schemas - agent/session-prefix (first step) - agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request -> prepare reasoning/default under turn signal -> log request/header -> checkpoint -> llm/stream (frozen, registration-bound) - on final adapter-path or terminal in-band failure: - 'step/end' - 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' - agent/step-result - 'assistant/message' (transformed content or empty success anchor after step-result rejection) - schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' - append accepted tool-batch context after all recorded results, then steering - agent/post-step -> checkpoint complete response/results - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step - 'turn/end' - checkpoint persistence and notify idle/running status + agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + 'assistant/chunk' + 'assistant/message' + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' + drain accepted tool context and steering + 'step/end' + continue for tools or steering unless a result concluded the turn + otherwise agent/turn-stopping -> drain -> continue only for steering + close the next-step acceptance window + 'turn/end' -> agent/settled + start the next waking queued message, or emit agent/status(idle) + +idle inject: + append 'user/message' + do not open a turn or run the model ``` -各步骤会组装有序提示词片段、工具 schema 和变量;未知引用会使轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `model` 和 `cwd`([归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 +每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。 -异步 `inject()` 和工具执行后的 `additionalContexts` 会在结果产生后稳定;steering(中途引导)会在 `agent/post-step` 前排空。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权,会丢弃后续 steering,而不丢弃排队提示词。 +接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用这一暂存边界,并请求再执行一个步骤。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 -裁剪先于摘要;溢出重试必须取得持久进展。有界重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 ### 失败边界 -适配器故障会先关闭步骤,再进入 `agent/request-error`;该事件会收到准确的 `Error`、`LlmFailure` 和历史记录。重试会开启步骤;成功会清除历史记录;重试耗尽后,故障存入 `turn/end`。失败分片不会提交任何内容。 +适配器故障会先关闭自身步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和信号。已处理的失败会关闭所在轮次,并从持久历史开启重试轮次,不发出空闲通知;重试耗尽则留下终态 `turn/end`。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`。取消和资源释放均优先于恢复;轮次信号还会在提交任何请求头之前取消异步模型能力准备,尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会发出原因、清空队列并中止;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 -会话事件均位于轮次边界内;重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障使用 `agent/error`。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 +轮次和步骤事件均位于轮次边界内;空闲时注入的 `user/message` 可以位于两个轮次之间。重新加载会用合成的轮次结束事件闭合中断尾部。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 ### Agent 句柄 -`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。 +`ctx.agents` 拥有活跃 agent,并返回 `AgentHandle { agent, dispose() }`。插件使用全部 `send()` 选项,或 `followup()`、`steer()` 和 `inject()` 预设;`cancel()` 与 `whenIdle()` 控制生命周期。一个需等待完成的 disposer 协调拆卸归属。 ### Agent 作用域 -每个 agent 都拥有一个作用于全局工具、提示词和命令存储的作用域化 `agent.ctx`([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md));作用域监听器会过滤分派,各项贡献会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合;类型化解析器从 `Events` 和 `scopeTarget` 推导载体检查([门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 ### 会话日志 -会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保留回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化共用该事件流。 +会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。 -**模型可见 ⟺ 已记录**:`step/start` 消息、请求头中的会话前缀和折叠后的 `request/header` 共同重建每个请求;`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言这一点([决策](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责;后端会缓冲同步的 `session/event` 通知。检查点会在适配器分发前排空,在工具分发前刷写已记录的顶层工具调用,在 `agent/post-step` 刷写完整的响应与结果批次,并刷写最终的轮次结束。`SessionPersistence` 存储 `SessionEvent` 和 `SessionHeader` 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 +持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于适配器分发前、顶层工具分发前,以及下一次请求的 `agent/step`。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 -消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;`MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 也采用同一模式定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 +消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 -流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告事实,`agent/request-error` 负责恢复。循环会记录分片及成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。只有当路由共用同一个适配器实例时,回放状态才会跨路由传递([契约](core-data-structures/llm-streaming.md))。 +流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会返回重试动作。循环会记录分片、成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。回放仅通过共用的适配器实例跨路由传递([契约](core-data-structures/llm-streaming.md))。 ## 扩展与组合 ### 功能模式 -可替换功能通常拆分为**接口/实现/消费方**:服务和事件、后端,以及面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 +可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。 -例外情况会合并不同层次:LLM(大语言模型)合并接口和消费方,文件系统整合策略,web 使用注册表,skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 +例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACP(Agent Client Protocol)子 agent([subagent.md](core-data-structures/subagent.md))。 -`dsh-workspace-context` 在 `agent/session-prefix` 上组合基线,并在通过 `ctx.fs` 发现嵌套变更后,于 `tools/post-execute` 追加这些变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录了隔离方式。`dsh-paths` 负责共享路径。 +`dsh-workspace-context` 在第一次 `agent/step` 注入基线,并通过 `tools/post-execute` 追加 `ctx.fs` 发现的变更;其[决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)记录隔离方式。`dsh-paths` 负责共享路径。 ### 组合包与应用 -`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 TUI、CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 仅在没有显式配置时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 +`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 TUI、CLI(命令行界面)、ACP 自动化入口和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[ui/](../packages/ui/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。 ### 新行为的归属位置 @@ -175,28 +173,21 @@ forever: | 目标 | 机制 | |---|---| -| 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | -| 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 | +| 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 | +| 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 加入提示词组装 | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` 生成进程 | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | -| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | +| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | -| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成进程前包装 argv | -| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stop` 是串行终止判定点 | -| 添加历史记录之外的会话稳定前缀 | 组合 `agent/session-prefix`;请求头会记录该前缀 | -| 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染;仅终端可用的浮层使用 `ctx.tui` | -| 添加持久会话状态 | 添加一个 `SessionEventMap` 成员,并从日志渲染和回放 | -| 添加异步会话标题生成 | 在 `ctx.sessionTitle` 上注册唯一提供方 | +| 限制生成的进程 | 使用 `ctx.sandbox` 后端;消费方在生成前包装 argv | +| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | +| 添加模型可见上下文 | 调用 `agent.inject()`,追加带来源的 `user/message`,但不创建轮次 | +| 添加 UI 或编辑器集成 | 驱动 `ctx.agents`,从 `session/event` 渲染;仅终端浮层使用 `ctx.tui` | +| 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | +| 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | -| fork 活跃会话 | 使用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | -| 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx`(参见 Agent 作用域) | +| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | +| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) | -[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架和功能到服务边界的映射;分步指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 - -## 快速参考 -- [术语表](glossary.md)中的领域术语 -- [core-data-structures/](core-data-structures/core.md) 中的类型定义 -- [事件](cordis-catalog/events.md)和[服务](cordis-catalog/services.md)目录中的准确签名 -- [包索引](../packages/README.md)中的包契约 -- [Agent Note(agent 决策记录)](../.agents/notes/README.md) +[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)提供插件骨架和功能到服务边界的映射;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..9093f41f15 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -35,6 +35,9 @@ flowchart LR pkg_tool_bash["tool-bash"] pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] + pkg_session_telemetry["session-telemetry"] + svc_telemetry["ctx.telemetry
Session telemetry seam"] + pkg_session_telemetry_otel["session-telemetry-otel"] pkg_storage["storage"] svc_storage["ctx.storage
Non-session storage hub"] pkg_storage_json["storage-json"] @@ -82,10 +85,15 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] - pkg_bash["bash"] - svc_bash["ctx.bash
Bash executor seam"] + pkg_subprocess["subprocess"] + svc_subprocess["ctx.subprocess
Subprocess seam"] + pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_lsp_local["lsp-local"] + pkg_subagent_acp["subagent-acp"] + pkg_bash["bash"] + svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -113,7 +121,6 @@ flowchart LR svc_subagents["ctx.subagents
Subagent provider registry"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_subagent_acp["subagent-acp"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] @@ -176,6 +183,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_telemetry --> svc_telemetry + pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle @@ -191,6 +200,8 @@ flowchart LR pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents + pkg_subprocess --> svc_subprocess + pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks @@ -263,6 +274,10 @@ flowchart LR svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subprocess --> pkg_bash_local + svc_subprocess --> pkg_bash_sandbox + svc_subprocess --> pkg_lsp_local + svc_subprocess --> pkg_subagent_acp svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -301,6 +316,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `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), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | @@ -317,6 +333,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `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.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ac29353224..61cc59c40f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -73,8 +73,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ goals?: agentCore.GoalConfig | false - /** Bounded transient model-request retry policy forwarded through agent-core. */ - llmRetry?: NonNullable } ``` @@ -110,7 +108,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:360`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:147`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -124,8 +122,9 @@ Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, `llmRetry` to the bounded request-recovery policy, - * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Provider adapters own their `retryPolicy`; this + * bundle always mounts its executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -161,8 +160,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Bounded transient model-request retry policy. */ - llmRetry?: llmRetry.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -186,12 +183,14 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` +Requires: `subprocess` + ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -210,11 +209,11 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `sandbox` · `sandboxPolicy` +Requires: `subprocess` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -262,8 +261,6 @@ 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'] } @@ -329,7 +326,7 @@ Requires: `llm` · `tokenMeter` export interface BasicCompactConfig extends CompactPolicyConfig { /** Exact provider/model overrides; duplicate targets fail plugin load. */ modelPolicies?: ModelCompactPolicyConfig[] - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + /** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } @@ -422,7 +419,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:55`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -458,7 +455,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:44`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -483,7 +480,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:42`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` @@ -584,6 +581,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** One optional model entry advertised by the direct-fetch adapter. */ @@ -599,7 +598,9 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) +Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) + +Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -636,12 +637,14 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -674,6 +677,8 @@ export interface ReplayProviderConfig { name?: string /** Advisory models exposed to replay scenarios that exercise discovery. */ models?: ReplayModelConfig[] + /** Optional provider-owned retry policy used by assembled recovery snapshots. */ + retryPolicy?: RetryPolicyConfig } /** One model exposed by a replay-only provider catalog. */ @@ -689,33 +694,24 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:598`](../packages/support/llm-replay/src/index.ts) +Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) + +Source: [`packages/support/llm-replay/src/index.ts:617`](../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[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` -Requires: `lsp` +Requires: `lsp` · `subprocess` ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -751,7 +747,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -918,7 +914,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:26`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -1096,6 +1092,40 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-telemetry-otel` + +Requires: `sessions` + +```ts config-catalog +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** + * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. + */ + exporter?: OTLPExporterNodeConfigBase & { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} +``` + +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) + +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -1112,7 +1142,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:70`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` @@ -1165,10 +1195,12 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ + bundledSkillDir?: string } ``` -Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1284,7 +1316,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/ ## `@deepseek-ai/dsh-subagent-acp` -Requires: `subagents` +Requires: `subagents` · `subprocess` ```ts config-catalog /** Config: how to spawn and drive the child ACP agent process. */ @@ -1494,7 +1526,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` @@ -1578,7 +1610,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:19`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:20`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` @@ -1720,11 +1752,11 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:578`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` · `tuiPrompt` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1770,21 +1802,30 @@ export interface TuiConfig { fileSearchExcludedDirectories?: string[] /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean - /** Apply the built-in ANSI color palette. */ - color?: boolean - /** - * Paint the startup banner's product name in the DeepSeek brand gradient - * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the - * flat accent color when either is off. Unset auto-detects `COLORTERM` at the - * process boundary, so most deployments leave it unset. - */ - truecolor?: boolean + /** Color and prompt-template settings. */ + theme?: TuiThemeConfig /** Terminal window title while the UI is mounted; a logged session title prefixes it. */ title?: string } + +/** Theme and prompt-template settings for the pi-tui terminal mode. */ +export interface TuiThemeConfig { + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Paint the startup banner with the 24-bit DeepSeek brand gradient. */ + truecolor?: boolean + /** Left-aligned template on the row above the editor. */ + leftPrompt?: string + /** Right-aligned template on the row above the editor. */ + rightPrompt?: string + /** Template used as the editor's first-line prefix. */ + inputPrompt?: string + /** Static placeholder shown in an empty editor while the agent is running. */ + inputPlaceholder?: string +} ``` -Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -2076,6 +2117,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) @@ -2095,6 +2137,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) +- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) @@ -2120,8 +2163,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) +- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 4c0e480bee..423737be39 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: c4deed8e13afcdc8e1a714364b086b8b0da018c1 -adding-a-tool.zh.md: 103c92b596a90192aae5a6b4d1e42fbcf052cf66 +adding-a-tool.md: d06e3d8e3c7da1f71a55bf9c4f56cd4b2cc03697 +adding-a-tool.zh.md: 53f608eba3b26b124f873990fa13ce1572c0baf2 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index c4deed8e13..d06e3d8e3c 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -46,7 +46,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Throwing or returning an invalid value means `isError`.** The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Project durable card data with `presentationMeta` (optional).** `output.presentationMeta(args, value)` derives replayable JSON from the same canonical value. The core persists it on `tool/result` and hands it to `presentResult`, so a card that needs result-time facts—such as `write`/`edit` applied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. -- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). +- **Use `exec.agent` for async notifications.** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). ## Long-running work diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 103c92b596..53f608eba3 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -46,7 +46,7 @@ export function apply(ctx: Context) { - **抛出异常或返回无效值意味着 `isError`。** 注册表会捕获异常,并在观察者运行前收敛 schema、渲染器、元数据投影器和无损 JSON 失败。基础设施故障请抛异常。成功的领域结果即使表示不理想的状态,也应写入规范值;其 Native 渲染器可以解释该状态,例如进程以非零状态退出。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `presentationMeta` 投影持久化的卡片数据(可选)。** `output.presentationMeta(args, value)` 从同一个规范值派生可回放的 JSON。核心将其持久化在 `tool/result` 上并传给 `presentResult`,因此需要结果期事实的卡片——例如 `write`/`edit` 的已应用 hunk——无需持久化规范值也能在回放中重现。嵌套 Code 分发没有卡片,因此会跳过该投影器。 -- **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 +- **使用 `exec.agent` 发送异步通知。** `agent.inject({ content, source: { kind: 'plugin', plugin: '' } })` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 ## 长时间运行的工作 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index a7e63abdeb..2a8096f502 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -1,6 +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 -extension-cookbook.md: 0ab337377518f832c0649bc80cf2941751cb934f -extension-cookbook.zh.md: f7c2572d0b91867589636249f29896848f8aec82 +# pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md +extension-cookbook.md: 51a87be037ddbf6d031d3607da7b70470087334c +extension-cookbook.zh.md: 389ac87be0a1cd14a7646374909d79e6e00d8b56 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 0ab3373775..51a87be037 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -54,7 +54,10 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })) } ``` @@ -97,12 +100,12 @@ Every product feature maps to a listener on a documented extension seam — the | Product feature | Plugin mechanism | |---|---| -| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | +| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams | | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control | | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue | -| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | +| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker | | Queued + steering messages | core `Agent.followup()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | @@ -110,7 +113,7 @@ Every product feature maps to a listener on a documented extension seam — the | ToolSearch / progressive disclosure | replace a scoped `ctx.tools.restrict()` registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned | | Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime | | Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context | -| Monotonic terminal turn policy | return `{ action: 'stop' }` from serial `agent/turn-stop`, after continuation and steering have already been folded | +| Monotonic terminal turn policy | call `ToolExecution.concludeTurn()` from the successful terminal tool; later tool calls in the same response remain guardable, and the loop stops after the step | | Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial | | Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions | | Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index f7c2572d0b..389ac87be0 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -54,7 +54,10 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })) } ``` @@ -97,12 +100,12 @@ export function apply(ctx: Context) { | 产品功能 | 插件机制 | |---|---| -| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | +| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 | | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 | -| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | +| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | @@ -110,7 +113,7 @@ export function apply(ctx: Context) { | ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 | | 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 | | 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` | -| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 | +| 单调终端轮次策略 | 从成功的终端工具调用 `ToolExecution.concludeTurn()`;同一响应中后续工具调用仍可由守卫阻止,循环在该步骤后停止 | | 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | | 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | | Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ef2f196dbf..1741aba456 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,15 +15,15 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/cancel-requested` — emit -Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. +Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. ```ts cordis-catalog /** - * Effective broad cancellation was requested, before queued/steering work + * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification * cannot veto cancellation; listener failures are contained. * @param agent - the agent whose current work is being cancelled. - * @param cause - resolved typed cancellation cause, including the default. + * @param cause - the explicit typed cancellation cause. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/steering work is clear Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,16 +54,16 @@ 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:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. +An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment. Custom registry users own their driver-ordering contract. ```ts cordis-catalog /** * An agent left the registry; AgentLoop emits this after driver quiescence - * but before session detachment and scoped-registration unwind. Custom + * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -74,16 +74,16 @@ 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:294`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit -A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. +A step or turn errored. The machine reports a failure here (plus the logger) even when the error has no in-turn position for a durable record. ```ts cordis-catalog /** - * A step or turn errored. The loop reports a failure here (plus the logger) - * even when the error has no in-turn position for a session `error` event. + * A step or turn errored. The machine reports a failure here (plus the + * logger) even when the error has no in-turn position for a durable record. * @param agent - the agent whose turn errored. * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. @@ -91,12 +91,12 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void +'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void ``` Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:498`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,21 +117,19 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit -Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` dropping pending steering (in-turn and on the post-turn late-steering drain); and disposal of any still-pending items (before `agent/status('disposed')`). Fires once per drop with every dropped item. +Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item. ```ts cordis-catalog /** * Pending inbox items were dropped without delivering them, so every * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR - * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after - * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop` - * dropping pending steering (in-turn and on the post-turn late-steering - * drain); and disposal of any still-pending items (before - * `agent/status('disposed')`). Fires once per drop with every dropped item. + * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, + * emits this after `agent/cancel-requested` when applicable and before + * aborting the active work. Fires once per drop with every dropped item. * @param agent - the agent whose inbox items were dropped. * @param messages - the discarded messages in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -142,91 +140,40 @@ Pending inbox items were dropped without delivering them, so every enqueued id r Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit -A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this. +An item entered the queued or steering inbox. `placement` is the acceptance-time routing result; listeners must not reconstruct it from later agent or session state. ```ts cordis-catalog /** - * A detached, frozen item entered the agent's inbox (queued or steering - * FIFO). Source defaults are already applied, so `message` holds the exact - * accepted values. This is the enqueue-time live signal; the durable record - * is the eventual `user/message`/`steering/message`. Injection through - * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs - * and does not emit this. - * @param agent - the agent whose inbox received the item. - * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts). + * An item entered the queued or steering inbox. `placement` is the + * acceptance-time routing result; listeners must not reconstruct it from + * later agent or session state. + * @param agent - the owning agent. + * @param message - accepted content, source, and correlation identity. + * @param placement - resolved queued or steering placement. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: AgentMessage, placement: InboxPlacement): void ``` -Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) - -### `agent/post-step` — serial - -Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal. - -```ts cordis-catalog -/** - * Awaited serial checkpoint after the response, real or synthetic tool - * results, injected context, and steering are durable but before `step/end`. - * A cancelled tool batch reaches this checkpoint with an aborted signal. - * @param agent - the agent whose step is settling. - * @param turn - the open turn number. - * @param step - the open step number. - * @param signal - the turn abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode serial - */ -'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:448`](../../packages/core/agent/src/types.ts) - -### `agent/pre-step` — serial - -Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history. `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - -```ts cordis-catalog -/** - * Awaited serial checkpoint before `step/start`; appends land outside the - * pending step and are included when the loop derives request history. - * `signal` cancels listener work. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - the agent opening the step. - * @param turn - the open turn number. - * @param step - the pending step number. - * @param signal - the turn abort signal. - * @mode serial - */ -'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void -``` - -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) - -Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. A listener wrapping a downstream `allow` must preserve its `content` and `additionalContexts` unless it intentionally replaces them. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn. Steering messages do not dispatch this event; they join an open turn at a steering checkpoint. +Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn. Call `next()` for the unchanged default. The signal controls only this admission attempt; listeners may cooperate with it but must not retain it for a later attempt or turn. ```ts cordis-catalog /** * Allow, rewrite, or block one claimed prompt before it becomes a user - * message. Call `next()` for the unchanged default. A listener wrapping a - * downstream `allow` must preserve its `content` and `additionalContexts` - * unless it intentionally replaces them. The signal controls only this turn; - * listeners may cooperate with it but must not retain it to control another - * turn. Steering messages do not dispatch this event; they join an open turn - * at a steering checkpoint. + * message or opens a turn. Call `next()` for the unchanged default. The + * signal controls only this admission attempt; listeners may cooperate with + * it but must not retain it for a later attempt or turn. * @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. @@ -239,84 +186,61 @@ 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:395`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. +Replace the frozen call configuration. `await next()` yields the config the machine would use (agent options on the first request, the logged header afterwards); return a replacement to switch. Model-visible content must use logged channels; this seam cannot mutate messages. ```ts cordis-catalog /** - * Replace the frozen call configuration. Model-visible content must use - * logged channels; this seam cannot mutate messages. Injection here joins - * the next request because the current step boundary is already fixed. + * Replace the frozen call configuration. `await next()` yields the config + * the machine would use (agent options on the first request, the logged + * header afterwards); return a replacement to switch. Model-visible + * content must use logged channels; this seam cannot mutate messages. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. - * @param config - the config the loop would use (frozen); return a replacement to switch. - * @param signal - the current turn's explicit abort signal; ambient - * initiator identity does not imply liveness or cancellation authority. + * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall - */ -'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise +*/ +'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise ``` 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:409`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall -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. +Handle a model-request failure after its failed step has closed but before the failed turn closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns the error, or calls `next()` to delegate. The default `undefined` leaves the failure terminal. ```ts cordis-catalog /** - * 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. + * Handle a model-request failure after its failed step has closed but + * before the failed turn closes. A listener returns `{ kind: 'retry' }` + * without calling `next()` when it owns the error, or calls `next()` to + * delegate. The default `undefined` leaves the failure terminal. * @param agent - the agent whose request failed. * @param turn - the open turn number. * @param step - the failed step number. * @param error - the original model-request failure. * @param failure - serializable facts normalized at the final adapter boundary. - * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence. + * @param priorFailures - immutable failures that already authorized another + * retry turn in this consecutive sequence. + * @param retryPolicy - immutable policy of the adapter registration that served + * the failed request, or `undefined` if no final adapter served it. * @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, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise): Promise +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise): Promise ``` -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) +Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) - -### `agent/session-prefix` — waterfall - -Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - -```ts cordis-catalog -/** - * Compose request-only messages placed before derived history. The frozen - * result is computed once per loop instance, logged on its anchoring request - * header, and reused so the provider prefix remains stable. Interrupted - * composition is discarded. Composition precedes the first `agent/pre-step` - * and request boundary, so listener appends join the current request. - * Changing context belongs in history; contributors should prepend to - * `await next()` to preserve registration order. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - the agent whose session prefix is being composed. - * @param prefix - the frozen seed; return an extended replacement. - * @param signal - the current turn's explicit abort signal. - * @mode waterfall - */ -'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise -``` - -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:424`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -338,16 +262,41 @@ 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:363`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) -### `agent/status` — emit +### `agent/settled` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking delivery does not enter `running` synchronously; drive lifecycle from this event. +One drain chain reached its terminal turn: that turn's `turn/end` is already committed. Automatically recovered failed turns do not emit this notification, and neither does a run that aborts or fails before its `turn/start` commits — there is no durable turn to settle against. `reason` says why; model-request recovery is exhausted when an error reaches it. ```ts cordis-catalog /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking - * delivery does not enter `running` synchronously; drive lifecycle from this event. + * One drain chain reached its terminal turn: that turn's `turn/end` is + * already committed. Automatically recovered failed turns do not emit this + * notification, and neither does a run that aborts or fails before its + * `turn/start` commits — there is no durable turn to settle against. + * `reason` says why; model-request recovery is exhausted when an error + * reaches it. + * @param agent - the agent whose turn closed. + * @param turn - the terminal turn number. + * @param reason - why the terminal turn ended, with live error facts when it failed. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/settled'(this: Scoped, agent: Agent, turn: number, reason: SettleReason): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) + +Source: [`packages/core/agent/src/types.ts:410`](../../packages/core/agent/src/types.ts) + +### `agent/status` — emit + +Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` synchronously; drive lifecycle from this event. + +```ts cordis-catalog +/** + * Agent status changed (`idle` ⇄ `running`). `send()` does not enter + * `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -358,74 +307,57 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking deliver 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:303`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) -### `agent/step-result` — waterfall +### `agent/step` — serial -Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). +Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation). The single "between steps" extension point: inject context, steer, or edit the session log here — the request's history derives from the log right after this settles. ```ts cordis-catalog /** - * Waterfall: post-process the assembled assistant {@link Message} before - * tool dispatch (validation, content rewriting, …). - * @param agent - the agent that received the step's response. + * Awaited serial checkpoint before EVERY request of a turn is built (the + * first as well as each post-tools continuation). The single "between + * steps" extension point: inject context, steer, or edit the session log + * here — the request's history derives from the log right after this settles. + * @param agent - the agent about to send a request. * @param turn - the open turn number. - * @param step - the step that produced the message. - * @param message - the assistant message as assembled from the stream. - * @param signal - the current turn's explicit abort signal. + * @param step - the step number about to open. + * @param signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall + * @mode serial */ -'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise +'agent/step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) -### `agent/turn-continuation` — waterfall +### `agent/turn-stopping` — serial -Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. +The turn is about to close: the model owes no response (no live tool calls, no fresh steering). Awaited before the boundary commits — a listener that objects steers (`agent.steer(...)`) and the machine re-reads its inbox: fresh steering runs another step, none closes the turn. Data decides, so listener order cannot change the outcome. The inverse control (stop a tool loop early) is data too: a tool result carrying `concludesTurn` ends the turn at its step. ```ts cordis-catalog /** - * Override whether the turn continues. The default continues after tool - * calls or steering and stops otherwise; a continue reason becomes steering. - * @param agent - the agent deciding whether to run another step. - * @param turn - the turn being continued or stopped. - * @param defaultDecision - what the loop would do absent an override. - * @param signal - the current turn's explicit abort signal. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @mode waterfall - */ -'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise -``` - -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:474`](../../packages/core/agent/src/types.ts) - -### `agent/turn-stop` — serial - -Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. - -```ts cordis-catalog -/** - * Monotonic terminal-stop checkpoint after continuation and steering are - * folded; a stop remains authoritative through turn close and flush: - * steering queued in that window is discarded, while ordinary sends survive. - * @param agent - the agent whose composed continuation outcome may be stopped. - * @param turn - the turn at its terminal-stop checkpoint. + * The turn is about to close: the model owes no response (no live tool + * calls, no fresh steering). Awaited before the boundary commits — a + * listener that objects steers (`agent.steer(...)`) and the machine + * re-reads its inbox: fresh steering runs another step, none closes the + * turn. Data decides, so listener order cannot change the outcome. The + * inverse control (stop a tool loop early) is data too: a tool result + * carrying `concludesTurn` ends the turn at its step. + * @param agent - the agent whose turn is at its stop boundary. + * @param turn - the turn about to close. * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ -'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined +'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -448,7 +380,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:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:140`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -591,7 +523,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:167`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts) ## `llm/*` @@ -616,7 +548,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:53`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:56`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -641,7 +573,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:70`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -662,7 +594,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:80`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -685,7 +617,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:92`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -706,7 +638,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts) ## `slash/*` @@ -892,6 +824,35 @@ Emitted when any prompt provider changes. This registry notification is unfilter Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +## `telemetry/*` + +### `telemetry/record` — waterfall + +Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +```ts cordis-catalog +/** + * Transform one outbound record before it reaches the backend. This + * waterfall is the seam's redaction extension point. It ships NO rules + * of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. Redaction applies to the exported copy only; the canonical + * session log is never rewritten. + * @param record - the candidate record, already the coordinator's own deep + * copy; listeners return a (possibly new) record and must not mutate it. + * @mode waterfall + */ +'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `tools/*` ### `tools/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6785e5f983..888d173df3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -27,7 +27,7 @@ create(id: SessionId, options: AgentOptions = {}, meta: Pick void */ listProviders(): LlmProviderInfo[] +/** + * Resolve the retry policy captured when one provider route was registered. + * @param provider - registered provider route to inspect. + * @returns the provider-owned policy, with normal defaults already resolved. + */ +providerRetryPolicy(provider: string): ResolvedRetryPolicy + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -778,9 +785,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:177`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:189`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -840,7 +847,7 @@ Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/ get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active from the next turn boundary. + * Select whether plan mode should be active from the next request boundary. * Repeated selection of the current or already-pending state is a no-op. * * @param agent The agent to switch. @@ -1210,7 +1217,7 @@ async listCandidates( agent: Agent, query = '', limit = this.config.candidateLim * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. * @param signal - optional cancellation boundary for host request teardown. - * @returns detached content and zero or one prepared contexts. + * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise ``` @@ -1366,7 +1373,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:611`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1400,7 +1407,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:284`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:283`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` @@ -1583,6 +1590,31 @@ Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun]( Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +## `ctx.subprocess` — `SubprocessService` (abstract seam) + +Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. +- Disposal of the service terminates all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). + */ +abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle +``` + +Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) + +Source: [`packages/subprocess/subprocess/src/index.ts:88`](../../packages/subprocess/subprocess/src/index.ts) + ## `ctx.systemPrompt` — `SystemPrompt` Registry service for the prompt inputs assembled before each model step. @@ -1726,6 +1758,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +## `ctx.telemetry` — `Telemetry` (abstract seam) + +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. + +```ts cordis-catalog +/** + * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home. + * @param record - the logical record to report; owned by the backend after the call. + */ +abstract emit(record: TelemetryRecord): void + +/** See {@link TelemetryBackend.flush}. */ +flush?(): void + +/** + * See {@link TelemetryBackend.shutdown}. + * @returns resolves when the backend's pipeline has quiesced. + */ +abstract shutdown(): Promise +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `ctx.tokenMeter` — `TokenMeterService` Replay owner for one service-wide estimator and isolated per-session folds. @@ -1879,7 +1934,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:700`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) @@ -1902,7 +1957,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:188`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 98855cdc0c..9d261fe939 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.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 -bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122 +bash.md: 3747244662301a256e12037ea67c21017b5ac2c5 +bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 35cf206158..3747244662 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,23 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the subprocess service removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -56,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -95,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined @@ -145,19 +136,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` @@ -192,8 +171,9 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -238,4 +218,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 0cfeb9e1a8..9927aa8d51 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,23 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。 - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -56,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -95,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined @@ -145,19 +136,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` @@ -192,8 +171,9 @@ interface BashSandboxInfo { ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -238,4 +218,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 3f192f0194..94b6bd4c5d 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -1,6 +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 -compaction.md: 71bbe7d9c17c6a3d35684a6c87d3072ab4f88df1 -compaction.zh.md: 35e9c9ef0050f01c5249d1502bb2782511acc819 +# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md +compaction.md: 3ba5edd96c509e064ac7033b175b7ddd3c972452 +compaction.zh.md: d082b0d0545802500278e96ca41a273bce275f53 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 71bbe7d9c1..3ba5edd96c 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -62,7 +62,7 @@ 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. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. 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/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/step` before request derivation. 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 returns a retry action 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, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 35e9c9ef00..d082b0d054 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -62,7 +62,7 @@ type CompactionTrigger = 'pressure' | 'context-overflow' `CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;消费方调用 `isCompactCheckpointSource()`,而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 -压力压缩在串行 `agent/post-step` 中运行:此时成功的 assistant 输出、工具结果、缓冲上下文和 steering(中途引导)已持久化,但 `step/end` 尚未发生。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才批准一个带新编号的步骤重试,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 该 seam 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包契约](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9b17460460..63d3cda07b 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -1,6 +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 -core.md: 86aea325f5401d728a9b4aa147d78db9163a32c1 -core.zh.md: d09fb48419c14959eefb5a1df1c593b36c188cf5 +# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md +core.md: 357712bd197ac2e0661e6bc61a638aa8a4738356 +core.zh.md: dcb7210d37377da99ac2cd68b1ce18fa6e90e0b8 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 86aea325f5..357712bd19 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | +| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | @@ -262,8 +263,7 @@ interface GenerateOptions { /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as - * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a - * hand-built one-shot passes any list. + * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ @@ -333,11 +333,11 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -401,7 +401,7 @@ type SessionEvent = { }[T] ``` -The thirteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the turn-enclosure invariant are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## The agent handle @@ -411,59 +411,51 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv /** - * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — during prompt admission or an open turn, the item stages for + * the next safe step boundary; otherwise it is promoted per its `wakeup` + * flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** Resolved inbox placement reported when an accepted message is enqueued. */ +type InboxPlacement = 'queued' | 'steering' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * The object is complete so routing policy is explicit. */ interface SendOptions { - source?: MessageSource + /** Queue the item joins. */ + target: SendTarget /** - * Model-facing contexts captured with this inbox item. A queued prompt exposes - * them through the default `agent/prompt-submit` allow decision, while steering - * records them directly at its next checkpoint. + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). A `false` + * `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). */ - contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue + wakeup: boolean } ``` -```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions { - /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ - source?: MessageSource - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} -``` +The fixed-preset aliases own `target` and `wakeup`; their `UserMessageData` input carries both content and provenance. -The advanced acceptance form makes every default explicit and rules out attached contexts on injection: +`send` returns the accepted message's opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events: ```ts type-equiv /** - * Fully specified input for {@link Agent.send}. Unlike the intent-named - * helpers, this form applies no defaults: callers provide content, source, - * contexts, metadata (including explicit `undefined`), target, and wakeup. - * The union excludes attached contexts from non-waking next-step injection. - */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) -``` - -FIFO delivery methods return an opaque `AgentMessageId`, stable across that message's `agent/inbox/*` events. Injection returns an id but bypasses those events: - -```ts type-equiv -/** - * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id - * on their `agent/inbox/*` events; injection bypasses those events. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -472,26 +464,14 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t ```ts type-equiv /** - * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.send}, - * stable across this message's enqueue, dequeue, and discard events. Source - * defaults, when applicable, are already applied, so these are the exact values - * the item was accepted with. - * `steering` is true for an item drained between steps; otherwise it is claimed - * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable - * model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ -interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.send}. */ +interface AgentMessage extends UserMessageData { + /** The id `send` returned for this message. */ id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO rather than the queued FIFO. */ - steering: boolean - /** Whether the item wakes the driver or requests another step. */ - wakeup: boolean } ``` @@ -514,10 +494,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -The structural `Agent` interface exposes four intent helpers plus the fully resolved acceptance method. The concrete driver implements the matrix once, and each helper supplies its fixed routing and defaults. +`Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +/** Public live-agent handle with aliases over the unified delivery primitive. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -527,90 +507,91 @@ interface Agent { readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** + * Whether a `next-step` send currently stages for prompt admission or the + * open turn. Unlike {@link status}, this excludes admission exit and turn + * settlement, when a waking `next-step` send becomes a queued follow-up. + */ + readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. - * Content, resolved source, and attached contexts are detached, validated, - * and frozen together; invalid input throws synchronously before notification - * or enqueue. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * It routes the caller's typed content and source as follows: + * + * - `next-turn` queues an item that becomes the sole ordinary message of its + * own FIFO-ordered turn; `wakeup:true` wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` stages steering during prompt admission + * or an open turn; outside that window it falls back to a woken + * `next-turn`. + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: admission or an open turn stages it for the + * next safe log position, while an injection outside that window appends + * immediately without opening a turn. If admission closes without a turn, + * a context-only boundary appends immediately; context staged beside + * steering remains pending with it. + * The agent snapshots and freezes `input` before publishing or queueing it. + * @param input - model-facing content and its producer provenance. + * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - followup(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Queue an ordinary message without waking an idle driver. The item retains - * FIFO order and is claimed only after another input wakes the driver. A lone - * queued item leaves `whenIdle()` resolved. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - queue(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Submit steering into the running turn and request another step. 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. Idle steering - * becomes a waking ordinary turn. - * @param content - the steering content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - steer(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before - * turn close even when interrupted. Idle injection uses a one-shot turn and - * durability checkpoint. Disposal awaits idle checkpoints; flush failures - * report through `agent/error`. An omitted source defaults to - * `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. - */ - inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId - - /** - * Accept one fully specified input through the same snapshot and routing path - * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; - * `next-step`/wakeup targets steering (falling back to an ordinary waking turn - * while idle); and `next-step` without wakeup injects durable context without - * running the model. Every field is mandatory and no source or routing default - * is applied. Invalid input throws synchronously before notification, enqueue, - * or append. - * @param input - the resolved content, attribution, context, metadata, and routing facts. - * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. - */ - send(input: ResolvedAgentInput): AgentMessageId + send(input: UserMessageData, options: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise + + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param input - prompt content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(input: UserMessageData): AgentMessageId + + /** + * Submit steering during prompt admission or an open turn — the + * `next-step`/wakeup preset of {@link send}. It stages for the next steering + * checkpoint before a request or stop decision. If the activity fails before + * that boundary, the remainder stays staged without waking the agent; retry + * or a later prompt takes it. Outside that window steering falls back to a + * woken follow-up turn, while cancellation or disposal may discard pending + * steering. + * @param input - steering content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(input: UserMessageData): AgentMessageId + + /** + * Append model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn + * stages it at the next safe log position; outside that window it appends + * immediately without opening a turn. If admission closes without a turn, + * a context-only boundary appends immediately; context staged beside + * steering remains pending with it. + * @param input - injected context and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(input: UserMessageData): AgentMessageId } ``` -`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: core declares `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'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `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 cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. 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. @@ -620,78 +601,37 @@ 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 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 user-role input, while JSON `meta` persists plugin state without exposing it to the model. Absent or `separate` placement becomes an injected `user/message` (plugin/goal source); `prompt-prefix` placement is available to prompt and steering inbox attachments and bakes the context before the effective request in the same message. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, metadata, and placement. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. +Prompt and post-tool decisions use the same `UserMessageData` content/source shape as durable user-role input. Each `additionalContexts` entry becomes a separate `user/message`, preserving its provenance. Hook bridges map their native decision fields onto these typed results. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** Model-facing context injected by a listener or atomically attached to one inbox message. */ -interface HookContext { - content: ContentBlock[] - source: MessageSource - /** - * Model placement. Absent or `separate` records an independent injected - * `user/message`; `prompt-prefix` prepends this context and a stable - * request delimiter to the same user-role message as its attached prompt. - */ - placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue -} -``` - -`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContexts` — or record `prompt/blocked` and end that zero-step turn as `rejected`): +`agent/prompt-submit` returns a `PromptDecision` before a turn opens. Allow may rewrite the claimed prompt or attach `additionalContexts`; block rejects admission without creating turn events: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement: separate context - * message by default, or a prefix inside the prompt's user-role message. - * `block` records a durable `prompt/blocked` and ends the claimed prompt's - * zero-step turn as rejected. An `allow` returned by a listener is - * authoritative: a listener wrapping `next()` preserves downstream `content` - * and `additionalContexts` unless it intentionally replaces them. + * Prompt interception result. `allow.content` replaces the prompt, while + * `additionalContexts` appends model-facing context before the turn starts. + * An `allow` returned by a listener is authoritative: a listener wrapping + * `next()` preserves both fields unless it intentionally replaces them. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] } | { 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 metadata — the typed `/goal` pattern): +`agent/request-error` runs after a failed model step closes and before its turn closes. Listeners can repair durable state or await policy work while the failed turn's signal is still live. A handling listener returns `{ kind: 'retry' }` without calling `next()`; the default `undefined` leaves the failure terminal. ```ts type-equiv -/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ -type ContinuationDecision = - | { action: 'stop' } - | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +/** Action returned by a listener that owns model-request recovery. */ +type RequestErrorAction = { kind: 'retry' } | undefined ``` -`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` 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()`. */ -type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } -``` - -`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end`. A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)`, and replayable facts remain in the session log rather than a transient payload. - -`agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. - -```ts type-equiv -/** - * The terminal subset of {@link ContinuationDecision}. A listener on - * `agent/turn-stop` returns this to make the already-composed continuation - * outcome terminal; `undefined` abstains. - */ -type ContinuationStop = Extract -``` +`agent/step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): @@ -700,8 +640,6 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides. - ## `ToolDefinition` The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d09fb48419..dcb7210d37 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -31,6 +31,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | | [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | @@ -268,8 +269,7 @@ interface GenerateOptions { /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as - * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a - * hand-built one-shot passes any list. + * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ @@ -339,11 +339,11 @@ interface ToolSchema { ### 请求信封:`LlmCallConfig` 与记录的 header -循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词、权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)以及会话前缀。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 +循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。`agent/session-prefix` 为每个循环实例组合一次仅用于请求的 prefix 消息,header 记录实际使用的确切结果。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 +`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求按此顺序读取:`system` 槽位(渲染后的提示词组装)→ `messagePrefix`(冻结的会话前缀)→ 派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。前缀从不进入派生历史;它的持久记录是 header 事件,开发不变式针对每个循环构建的请求精确重算此等式。 +在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -407,7 +407,7 @@ type SessionEvent = { }[T] ``` -十三种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`prompt/blocked`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及轮次封闭不变量都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 @@ -419,59 +419,51 @@ type SessionEvent = { ```ts type-equiv /** - * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}. - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. + * Which inbox queue a {@link Agent.send} item joins: + * - `next-turn` — the item becomes its own turn, claimed at a turn boundary. + * - `next-step` — during prompt admission or an open turn, the item stages for + * the next safe step boundary; otherwise it is promoted per its `wakeup` + * flag. + */ +type SendTarget = 'next-turn' | 'next-step' +``` + +```ts type-equiv +/** Resolved inbox placement reported when an accepted message is enqueued. */ +type InboxPlacement = 'queued' | 'steering' +``` + +```ts type-equiv +/** + * Options for the unified {@link Agent.send} primitive over the + * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} + * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and + * {@link Agent.inject} (`next-step`/no-wakeup). + * + * The object is complete so routing policy is explicit. */ interface SendOptions { - source?: MessageSource + /** Queue the item joins. */ + target: SendTarget /** - * Model-facing contexts captured with this inbox item. A queued prompt exposes - * them through the default `agent/prompt-submit` allow decision, while steering - * records them directly at its next checkpoint. + * Whether this item makes the model run: wake a parked driver (`next-turn`) + * or force a continuation step (`next-step` while running). A `false` + * `next-turn` item queues without waking; a `false` + * `next-step` item attaches durable context without forcing another step + * (the injection preset). */ - contexts?: HookContext[] - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue + wakeup: boolean } ``` -```ts type-equiv -/** Options specific to durable synthetic context injection. */ -interface InjectOptions { - /** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */ - source?: MessageSource - /** Opaque JSON state retained on the durable message but hidden from the model. */ - meta?: JsonValue -} -``` +固定预设的别名方法自带 `target` 与 `wakeup`;其 `UserMessageData` 输入同时携带内容与 provenance。 -高级接收形式会显式给出所有默认值,并禁止为注入附加上下文: +`send` 返回被接收消息的不透明 `AgentMessageId`,该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定: ```ts type-equiv /** - * Fully specified input for {@link Agent.send}. Unlike the intent-named - * helpers, this form applies no defaults: callers provide content, source, - * contexts, metadata (including explicit `undefined`), target, and wakeup. - * The union excludes attached contexts from non-waking next-step injection. - */ -type ResolvedAgentInput = { - content: ContentBlock[] - source: MessageSource - meta: JsonValue | undefined -} & ( - | { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] } - | { target: 'next-step'; wakeup: true; contexts: HookContext[] } - | { target: 'next-step'; wakeup: false; contexts: [] } -) -``` - -FIFO 投递方法返回不透明的 `AgentMessageId`,该 id 在同一条消息的各个 `agent/inbox/*` 事件中保持稳定。注入也返回 id,但会绕过这些事件: - -```ts type-equiv -/** - * Opaque id assigned to one accepted agent input. FIFO inputs carry the same id - * on their `agent/inbox/*` events; injection bypasses those events. + * Opaque id assigned to one accepted {@link Agent.send} message; returned by + * `send` and carried on its `agent/inbox/*` events for correlation. */ type AgentMessageId = Branded<'AgentMessageId'> ``` @@ -480,26 +472,14 @@ type AgentMessageId = Branded<'AgentMessageId'> ```ts type-equiv /** - * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id` - * is the value returned by the accepting helper or {@link Agent.send}, - * stable across this message's enqueue, dequeue, and discard events. Source - * defaults, when applicable, are already applied, so these are the exact values - * the item was accepted with. - * `steering` is true for an item drained between steps; otherwise it is claimed - * at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable - * model-hidden state that lands on the eventual `user/message`/ - * `steering/message`, not live-event routing data. + * One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live + * events. `id` is the value `send` returned to the caller, stable across this + * message's enqueue, dequeue, and discard events. The agent snapshots and + * freezes the accepted content and source before enqueue observers receive it. */ -interface AgentMessage { - /** The id returned by the accepting helper or {@link Agent.send}. */ +interface AgentMessage extends UserMessageData { + /** The id `send` returned for this message. */ id: AgentMessageId - content: ContentBlock[] - source: MessageSource - contexts: HookContext[] - /** Whether the item joined the steering FIFO rather than the queued FIFO. */ - steering: boolean - /** Whether the item wakes the driver or requests another step. */ - wakeup: boolean } ``` @@ -522,10 +502,10 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -结构化 `Agent` 接口公开四个按意图命名的辅助方法,以及接受完全解析输入的方法。具体驱动器只需实现一次这套路由矩阵,每个辅助方法提供其固定路由与默认值。 +`Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 ```ts type-equiv -/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ +/** Public live-agent handle with aliases over the unified delivery primitive. */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId @@ -535,90 +515,91 @@ interface Agent { readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus + /** + * Whether a `next-step` send currently stages for prompt admission or the + * open turn. Unlike {@link status}, this excludes admission exit and turn + * settlement, when a waking `next-step` send becomes a queued follow-up. + */ + readonly acceptsNextStep: boolean /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue an ordinary message as its own FIFO-ordered turn and wake the driver. - * Content, resolved source, and attached contexts are detached, validated, - * and frozen together; invalid input throws synchronously before notification - * or enqueue. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. + * The unified delivery primitive over the (`target` × `wakeup`) matrix. + * It routes the caller's typed content and source as follows: + * + * - `next-turn` queues an item that becomes the sole ordinary message of its + * own FIFO-ordered turn; `wakeup:true` wakes a + * parked driver, while `wakeup:false` queues without waking. + * - `next-step` with `wakeup:true` stages steering during prompt admission + * or an open turn; outside that window it falls back to a woken + * `next-turn`. + * - `next-step` with `wakeup:false` injects durable model-facing context + * without running the model: admission or an open turn stages it for the + * next safe log position, while an injection outside that window appends + * immediately without opening a turn. If admission closes without a turn, + * a context-only boundary appends immediately; context staged beside + * steering remains pending with it. + * The agent snapshots and freezes `input` before publishing or queueing it. + * @param input - model-facing content and its producer provenance. + * @param options - target queue and wakeup decision. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - followup(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Queue an ordinary message without waking an idle driver. The item retains - * FIFO order and is claimed only after another input wakes the driver. A lone - * queued item leaves `whenIdle()` resolved. - * @param content - the prompt content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - queue(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Submit steering into the running turn and request another step. 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. Idle steering - * becomes a waking ordinary turn. - * @param content - the steering content blocks. - * @param options - source, attached contexts, and durable model-hidden meta. - * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. - */ - steer(content: ContentBlock[], options?: SendOptions): AgentMessageId - - /** - * Append detached model-facing context without running the model. An open-turn - * injection joins at the current log position unless the current tool batch is - * executing; then it waits FIFO until that batch settles and drains before - * turn close even when interrupted. Idle injection uses a one-shot turn and - * durability checkpoint. Disposal awaits idle checkpoints; flush failures - * report through `agent/error`. An omitted source defaults to - * `{ kind: 'plugin', plugin: '' }`. - * @param content - the injected context content blocks. - * @param options - source and durable model-hidden meta. - * @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events. - */ - inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId - - /** - * Accept one fully specified input through the same snapshot and routing path - * as the four intent-named helpers. `next-turn` targets the ordinary FIFO; - * `next-step`/wakeup targets steering (falling back to an ordinary waking turn - * while idle); and `next-step` without wakeup injects durable context without - * running the model. Every field is mandatory and no source or routing default - * is applied. Invalid input throws synchronously before notification, enqueue, - * or append. - * @param input - the resolved content, attribution, context, metadata, and routing facts. - * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable. - */ - send(input: ResolvedAgentInput): AgentMessageId + send(input: UserMessageData, options: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the * resolved typed cause. The first cause wins for the active turn, and - * `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause - * means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm - * later work. The active turn snapshots and freezes the cause. + * `whenIdle()` resolves after cancellation reaches quiescence. Idle + * cancellation is a no-op and does not arm later work. * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - cancel(cause?: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise + + /** + * Queue an ordinary follow-up turn and wake the driver — the + * `next-turn`/wakeup preset of {@link send}. The item becomes the sole + * ordinary message of its own turn. + * @param input - prompt content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + followup(input: UserMessageData): AgentMessageId + + /** + * Submit steering during prompt admission or an open turn — the + * `next-step`/wakeup preset of {@link send}. It stages for the next steering + * checkpoint before a request or stop decision. If the activity fails before + * that boundary, the remainder stays staged without waking the agent; retry + * or a later prompt takes it. Outside that window steering falls back to a + * woken follow-up turn, while cancellation or disposal may discard pending + * steering. + * @param input - steering content and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + steer(input: UserMessageData): AgentMessageId + + /** + * Append model-facing context without running the model — the + * `next-step`/no-wakeup preset of {@link send}. Admission or an open turn + * stages it at the next safe log position; outside that window it appends + * immediately without opening a turn. If admission closes without a turn, + * a context-only boundary appends immediately; context staged beside + * steering remains pending with it. + * @param input - injected context and its producer provenance. + * @returns the accepted message's {@link AgentMessageId}. + */ + inject(input: UserMessageData): AgentMessageId } ``` -`AgentStatus` 为 `'idle' | 'running' | 'disposed'`,`SessionId` 是品牌类型。`running` 描述整个驱动器的排空区间,可能跨越轮次关闭、其持久化检查点以及连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 +`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 @@ -628,78 +609,37 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancella ## 拦截决策 -每个 `agent/*` 拦截 waterfall 都返回一个小型、特定于 seam 的类型化联合——统一的 Decision 惯用形状([tools.md](tools.md) 中工具 seam 的 `PreToolDecision`/`PostToolDecision` 也采用相同形状)。CC/Codex 钩子桥接层把其 `permissionDecision`/`decision`/`continue`/`additionalContext` 字段映射到这些联合上;原生插件则直接返回它们。提示词决策与工具后决策共享一种面向模型的上下文形状 `HookContext`,它必须携带 `source`(缺少 source 会默认成 `{kind:'user'}`,从而把插件上下文错标为用户提示词)。其中的 `content` 作为 user-role 输入逐字到达模型,而 JSON `meta` 持久保存插件状态但不向模型暴露。未指定放置方式或指定为 `separate` 时,上下文会成为一条注入的 `user/message`(来源类别为插件或 goal);`prompt-prefix` 放置方式可用于提示词和 steering 收件箱附件,会在同一条消息中把上下文置于最终生效的请求之前。两种决策都携带 `additionalContexts[]`,使每一项保留各自的 provenance、元数据与放置方式。Continuation reason 则是 steering 消息,并有意使用更窄的 content/source 形状。 +提示词决策与工具后决策使用与持久 user-role 输入相同的 `UserMessageData` content/source 形状。每个 `additionalContexts` 条目都会成为一条独立的 `user/message`,保留各自的 provenance。钩子桥接层把其原生决策字段映射到这些类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -```ts type-equiv -/** Model-facing context injected by a listener or atomically attached to one inbox message. */ -interface HookContext { - content: ContentBlock[] - source: MessageSource - /** - * Model placement. Absent or `separate` records an independent injected - * `user/message`; `prompt-prefix` prepends this context and a stable - * request delimiter to the same user-role message as its attached prompt. - */ - placement?: 'separate' | 'prompt-prefix' - /** Opaque JSON state retained in the session event but hidden from the model. */ - meta?: JsonValue -} -``` - -`agent/prompt-submit` 返回 `PromptDecision`(允许该轮次已领取的排队消息——可选地改写其 `content` 或附加 `additionalContexts`——或者记录 `prompt/blocked` 并以 `rejected` 结束这个零步骤轮次): +`agent/prompt-submit` 在轮次打开前返回 `PromptDecision`。allow 可以改写已领取的提示词或附加 `additionalContexts`;block 拒绝准入且不产生任何轮次事件: ```ts type-equiv /** - * Prompt interception result. `allow.content` replaces the prompt. Each - * `additionalContexts` entry follows its declared placement: separate context - * message by default, or a prefix inside the prompt's user-role message. - * `block` records a durable `prompt/blocked` and ends the claimed prompt's - * zero-step turn as rejected. An `allow` returned by a listener is - * authoritative: a listener wrapping `next()` preserves downstream `content` - * and `additionalContexts` unless it intentionally replaces them. + * Prompt interception result. `allow.content` replaces the prompt, while + * `additionalContexts` appends model-facing context before the turn starts. + * An `allow` returned by a listener is authoritative: a listener wrapping + * `next()` preserves both fields unless it intentionally replaces them. */ type PromptDecision = - | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] } | { kind: 'block'; reason: string } ``` -`agent/turn-continuation` 返回 `ContinuationDecision`(步骤有工具调用或注入了 steering 时,循环默认为 `continue`,否则为 `stop`;`continue` 的 `reason` 会记录为同一轮次中下一个步骤的 steering,因此不携带上下文元数据——即类型化 `/goal` 模式): +`agent/request-error` 在失败的模型步骤关闭之后、其轮次关闭之前运行。listener 可以在失败轮次的 signal 仍然存活时修复持久状态或 await 策略工作。处理该错误的 listener 返回 `{ kind: 'retry' }` 且不调用 `next()`;默认的 `undefined` 会让失败保持终态。 ```ts type-equiv -/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ -type ContinuationDecision = - | { action: 'stop' } - | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +/** Action returned by a listener that owns model-request recovery. */ +type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/request-error` 接收确切的原始 `RequestError`、其不可变 `LlmFailure`、在连续序列中已批准另一次请求的不可变失败列表、轮次信号以及 `next()`。恢复插件按 `failure.code` 路由,而不是按活跃错误的消息路由;每项策略只统计自身的 code,一次成功请求会清空历史: - ```ts type-equiv /** Model-request failure with an optional machine-routable provider code. */ type RequestError = Error & { code?: string } ``` -它返回 `RequestErrorDecision`;`retry` 在恢复 listener 的持久变更之后打开一个带新编号的步骤,而 `fail` 在 `turn/end` 上保留结构化失败: - -```ts type-equiv -/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ -type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } -``` - -`agent/post-step` 会在 assistant 输出、真实或合成的工具结果、缓冲上下文与 steering 持久化之后、`step/end` 之前被 await。被取消的工具批次在排空后携带 aborted signal 到达这里;其签名为 `(agent, turn, step, signal)`,可回放事实保留在会话日志中,而不是瞬态 payload 中。 - -`agent/turn-stop` 返回仅停止的 `ContinuationStop` 子集或 `undefined`。循环在折叠普通决策、其 reason 和待处理 steering 之后调用此串行检查点;stop 是终态,会丢弃待处理的 steering。 - -```ts type-equiv -/** - * The terminal subset of {@link ContinuationDecision}. A listener on - * `agent/turn-stop` returns this to make the already-composed continuation - * outcome terminal; `undefined` abstains. - */ -type ContinuationStop = Extract -``` +`agent/step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): @@ -708,8 +648,6 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` 在每个循环实例中组合一次 `Message[]`。深度冻结的结果被记录在请求 header 中,并前置于每次派生历史,使其成为会话稳定开场白的归属。恢复的实例会重新组合;会话中途的变更使用仅追加的上下文通道。该 waterfall 直接返回内容,因为它是贡献而非决策。 - ## `ToolDefinition` 唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的契约。 diff --git a/docs/core-data-structures/goal.i18n.yaml b/docs/core-data-structures/goal.i18n.yaml index 47dc0c1b1c..0cdd666254 100644 --- a/docs/core-data-structures/goal.i18n.yaml +++ b/docs/core-data-structures/goal.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 -goal.md: 2e8d296eeda6e5f69c0f92829e347b7f55f41fa9 -goal.zh.md: a9c946e7cd37cf948c7ac0f3e4d0ea35ac80d614 +goal.md: 704a93320cc38d1b9400edc2d9ad2342bc11dccd +goal.zh.md: b2e083843a70823bf6a6b43e046b1f38f4e11e22 diff --git a/docs/core-data-structures/goal.md b/docs/core-data-structures/goal.md index 2e8d296eed..704a93320c 100644 --- a/docs/core-data-structures/goal.md +++ b/docs/core-data-structures/goal.md @@ -107,6 +107,8 @@ interface GoalMessageSource { readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number + /** Complete durable mutation carried only by round-zero state-change messages. */ + readonly change?: GoalChangeMeta } ``` diff --git a/docs/core-data-structures/goal.zh.md b/docs/core-data-structures/goal.zh.md index a9c946e7cd..b2e083843a 100644 --- a/docs/core-data-structures/goal.zh.md +++ b/docs/core-data-structures/goal.zh.md @@ -107,6 +107,8 @@ interface GoalMessageSource { readonly revision: number /** Zero for state changes; positive for admitted continuation rounds. */ readonly round: number + /** Complete durable mutation carried only by round-zero state-change messages. */ + readonly change?: GoalChangeMeta } ``` diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 3d35da5691..9f1e4f440a 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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 docs/core-data-structures/llm-streaming.md -llm-streaming.md: 9151ba569af6b640144c1850d616b6f762b68a8b -llm-streaming.zh.md: ae4a6843b9bf4f09169b17271c7edea76f8f2051 +llm-streaming.md: db46deee28cd053d034f889eb7625c9f222b418b +llm-streaming.zh.md: fbff50bf1f3b86afd313c2d5020c15dd88e0b449 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 9151ba569a..db46deee28 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -59,16 +59,20 @@ 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, 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. +- **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 plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; 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 turn; 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. - **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). - **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. +- **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. 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 is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) 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. +## `ResolvedRetryPolicy` + +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the exact serving registration's captured value after that call enters its final adapter boundary, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) owns the optional input shapes. + ## `AppIdentity` — app attribution The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). @@ -157,7 +161,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -189,6 +193,12 @@ declare abstract class LlmAdapter { * @returns detached display metadata whose id must equal `provider`. */ providerInfo(provider: string): LlmProviderInfo; + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index ae4a6843b9..fbff50bf1f 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -59,16 +59,20 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实关联到该调用;agent loop(智能体循环)关闭失败的步骤,再把错误、事实与不可变的先前已重试事实提供给 `agent/request-error`。若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 -- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 +- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。 -- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 +- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。 该契约由两个有意保持独立的实现锁定:`dsh-llm-deepseek`(直接 fetch,SSE(Server-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`(通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。 +## `ResolvedRetryPolicy` + +提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用进入最终适配器边界后,`llmRetryPolicyOf(stream)` 返回为其提供服务的确切注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选输入形状由[生成的配置目录](../config-catalog.md)规定。 + ## `AppIdentity`:应用归属 每个适配器都会向提供方发送的静态公开应用标识([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts))。`attributionHeaders(identity?)` 只把它映射到标准 `User-Agent` header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 `APP_IDENTITY` 从包(package) manifest(元数据清单)获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见[强制 `User-Agent` 归属](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。 @@ -157,7 +161,7 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerInfo()` 与异步 `listModels()` 方法为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -189,6 +193,12 @@ declare abstract class LlmAdapter { * @returns detached display metadata whose id must equal `provider`. */ providerInfo(provider: string): LlmProviderInfo; + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and diff --git a/docs/core-data-structures/session-reference.i18n.yaml b/docs/core-data-structures/session-reference.i18n.yaml index 2fd211e4e2..27d4d6fc3d 100644 --- a/docs/core-data-structures/session-reference.i18n.yaml +++ b/docs/core-data-structures/session-reference.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 -session-reference.md: 4898cdd641427a023dde63bfc9759300964c7fac -session-reference.zh.md: 8e36d70241f2565a587bd3c1ee270d99dba47d71 +session-reference.md: a19df1702429be23ff6ef4f7062a68b8286c3644 +session-reference.zh.md: 4a8b7c2b9dcb02f130d551d4951a771328a842b9 diff --git a/docs/core-data-structures/session-reference.md b/docs/core-data-structures/session-reference.md index 4898cdd641..a19df17024 100644 --- a/docs/core-data-structures/session-reference.md +++ b/docs/core-data-structures/session-reference.md @@ -38,15 +38,15 @@ interface SessionReferenceCandidate { ## Prepared messages -Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call. +Preparation preserves readable current-message content and returns at most one aggregated context. ```ts type-equiv -/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +/** Direct message content and optional referenced-session context. */ interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ content: ContentBlock[] - /** Empty without references; otherwise one aggregated untrusted context. */ - contexts: HookContext[] + /** Aggregated untrusted snapshot, absent when the message has no references. */ + additionalContext?: UserMessageData } ``` diff --git a/docs/core-data-structures/session-reference.zh.md b/docs/core-data-structures/session-reference.zh.md index 8e36d70241..4a8b7c2b9d 100644 --- a/docs/core-data-structures/session-reference.zh.md +++ b/docs/core-data-structures/session-reference.zh.md @@ -38,15 +38,15 @@ interface SessionReferenceCandidate { ## 预备消息 -预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。宿主会把 `contexts` 绑定到该次确切的 `followup()` 或 `steer()` 调用。 +预备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 ```ts type-equiv -/** Message payload and the zero-or-one durable snapshot contexts bound to it. */ +/** Direct message content and optional referenced-session context. */ interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ content: ContentBlock[] - /** Empty without references; otherwise one aggregated untrusted context. */ - contexts: HookContext[] + /** Aggregated untrusted snapshot, absent when the message has no references. */ + additionalContext?: UserMessageData } ``` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 1797724b9d..539beff405 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.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 docs/core-data-structures/session.md -session.md: 59bcf027c3ec728c5583478c7d17bd6eef2dc2fa -session.zh.md: 01b6f080b5fcf7b83ca46b99a8d80113a6ebe8bf +session.md: 058236cb628f0e517fe18b0e4276dba46bd6b0b0 +session.zh.md: 2d8022c7892828a30094728a1449d16dddd2fd89 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 59bcf027c3..058236cb62 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -12,28 +12,17 @@ The append-only event types. Merge-extensible: a plugin declares extra event typ ```ts type-equiv /** - * Shared payload for user, injected-context, and steering prompt messages. A + * Shared payload for user, injected-context, and steering messages. A * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ -interface PromptMessageData { - /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ +interface UserMessageData { + /** Exact model-facing blocks. */ content: ContentBlock[] - /** Producer provenance for the direct prompt. */ + /** Producer provenance. */ source: MessageSource - /** Present only when prompt-prefix contexts were baked into `content`. */ - envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a 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. - */ - meta?: JsonValue } ``` @@ -46,10 +35,7 @@ interface PromptMessageData { */ interface SessionEventMap { /** - * 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). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } /** @@ -68,16 +54,10 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ - 'user/message': PromptMessageData - /** - * 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. - */ - 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + 'user/message': UserMessageData /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -114,7 +94,7 @@ interface SessionEventMap { meta?: JsonValue } /** Steering content injected between steps of a running turn. */ - 'steering/message': PromptMessageData & { turn: number } + 'steering/message': UserMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -125,7 +105,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` is always the exact model-facing content. When attached context declares `prompt-prefix` placement, AgentLoop concatenates its blocks, a `## My request:` delimiter, and the effective direct prompt into that array. The optional model-hidden `envelope` retains `displayContent` plus ordered prefix-context source/metadata descriptors, so transcript, title, and re-reference consumers can present the human prompt without changing reconstructable history. `displayPromptContent()` performs that selection and falls back to `content` for ordinary and older events. +`UserMessageData` is the durable `content` and `source` base shared by ordinary prompts, injected context, and steering. Live inbox events extend the same shape with an `AgentMessageId`; the loop adds only driver-owned routing state while an item remains pending. ### `OutOfBandSessionEventMap` — narrow late-append opt-in @@ -166,13 +146,13 @@ interface TodoItem { ### The request header event: `request/header` -The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. +The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, - * tools, and prefix. The latest full `request/header` snapshot reconstructs it; - * canonical empty optional fields are absent. + * Logged request state outside derived history: call config, system prompt, and + * tools. The latest full `request/header` snapshot reconstructs it; canonical + * empty optional fields are absent. */ interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ @@ -181,18 +161,10 @@ interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] - /** - * The session prefix: request-only messages sent BEFORE the entire derived - * history (the `agent/session-prefix` waterfall's product, composed once - * per loop instance and reused for every request it sends). Not session - * history — `deriveMessages()` never returns it — so the header is its - * only durable record; absent when the instance composed none. - */ - messagePrefix?: Message[] } ``` -Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are absent fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); it is composed once per loop instance and included in every full snapshot that instance records. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. +Canonical form represents an empty system prompt or tool list as an absent field, matching how requests are built. Legacy v0 logs containing the removed `request/header-delta` event or its full-snapshot `fallback` reason are rejected at seed, append, and persistence-load boundaries rather than replayed incompletely. ## `SessionEvent` — one log entry @@ -370,6 +342,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused @@ -472,7 +456,7 @@ declare class Session { - `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata. - `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. -- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position. Optional JSON `meta` remains in the event log and is never rendered. +- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; provenance and domain data live in its typed source. - `steering/message` → a user-role message carrying exact `content` at its chronological position; an optional envelope remains log-only display metadata. 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. @@ -494,14 +478,12 @@ An explicit `boundary` lets callers fork from a previous completed turn even if */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** - * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `user/message` (a non-`user` source, - * plugin by default) in a one-shot turn (`turn/start` → `user/message` → - * `turn/end`) so every event in the log stays turn-enclosed — the - * durability/replay boundary is the turn, and a bare event between turns would - * otherwise be indistinguishable from a crash tail on reload. The trigger's - * `source` mirrors that message's producer. + * An out-of-band producer explicitly enclosed injected context in a one-shot + * turn. `Agent.inject()` appends idle context directly and does not use this + * trigger; the source mirrors the producer of the enclosed `user/message`. */ injection: { kind: 'injection'; source: MessageSource } } @@ -524,7 +506,8 @@ interface TurnEndReasonMap { * 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`). Final model-request failures retain their normalized facts - * as one `failure`; other turn failures retain their live Error message/code. + * as one `failure`; other thrown values retain their rendered message and a + * real `HarnessError` code when present. */ error: { kind: 'error'; step: number } & ( | { failure: LlmFailure; message?: never; code?: never } @@ -533,11 +516,6 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } - /** - * 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 } /** * A persistence backend closed a crash-orphaned turn on reload. The loop never * emits this marker, and the events recorded before the crash remain intact. @@ -546,7 +524,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 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. +`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. `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 @@ -556,7 +534,7 @@ Every session event lives **inside** a turn (between a `turn/start` and its `tur A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). -The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `user/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). +The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` and the pre-turn `UserPromptSubmit` admission seam get no `hook/*` record because neither has an open turn to enclose one; allowed context is instead evidenced by its sourced `user/message` (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 01b6f080b5..2d8022c789 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -12,28 +12,17 @@ ```ts type-equiv /** - * Shared payload for user, injected-context, and steering prompt messages. A + * Shared payload for user, injected-context, and steering messages. A * direct human prompt, a synthetic `agent.inject()` context, and mid-turn * steering all project into the model transcript as verbatim user-role content; * they are told apart by `source` (a non-`user` kind marks injected context), - * not by event type. `meta` carries durable model-hidden producer state. + * not by event type. */ -interface PromptMessageData { - /** Exact model-facing blocks, including any baked prompt-prefix contexts. */ +interface UserMessageData { + /** Exact model-facing blocks. */ content: ContentBlock[] - /** Producer provenance for the direct prompt. */ + /** Producer provenance. */ source: MessageSource - /** Present only when prompt-prefix contexts were baked into `content`. */ - envelope?: PromptMessageEnvelope - /** - * Opaque durable JSON state retained on the event but hidden from the model - * projection. It is the intended channel for a 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. - */ - meta?: JsonValue } ``` @@ -46,10 +35,7 @@ interface PromptMessageData { */ interface SessionEventMap { /** - * 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). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } /** @@ -68,16 +54,10 @@ interface SessionEventMap { * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ - 'user/message': PromptMessageData - /** - * 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. - */ - 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } + 'user/message': UserMessageData /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** @@ -114,7 +94,7 @@ interface SessionEventMap { meta?: JsonValue } /** Steering content injected between steps of a running turn. */ - 'steering/message': PromptMessageData & { turn: number } + 'steering/message': UserMessageData & { turn: number } /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** @@ -125,7 +105,7 @@ interface SessionEventMap { } ``` -`PromptMessageData.content` 始终是确切的模型可见内容。当附加上下文声明 `prompt-prefix` 放置方式时,AgentLoop 会依次把它的块、一个 `## My request:` 分隔符以及最终生效的直接提示词拼接进该数组。可选且对模型隐藏的 `envelope` 会保留 `displayContent`,以及按顺序排列的前缀上下文来源/元数据描述信息,使 transcript(文本记录)、标题与重新引用消费方无需改变可重建历史,就能呈现人类提示词。`displayPromptContent()` 负责该选择,并为普通事件和较早的事件回退到 `content`。 +`UserMessageData` 是普通提示词、注入上下文与 steering(中途引导)共享的持久 `content` + `source` 基础形状。实时收件箱事件在同一形状上扩展一个 `AgentMessageId`;条目待处理期间,loop 只额外附加驱动器自有的路由状态。 ### `OutOfBandSessionEventMap`:受限的带外追加显式准入 @@ -168,13 +148,13 @@ interface TodoItem { ### 请求头事件:`request/header` -请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema + 会话前缀)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 +请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。 ```ts type-equiv /** - * Logged request state outside derived history: call config, system prompt, - * tools, and prefix. The latest full `request/header` snapshot reconstructs it; - * canonical empty optional fields are absent. + * Logged request state outside derived history: call config, system prompt, and + * tools. The latest full `request/header` snapshot reconstructs it; canonical + * empty optional fields are absent. */ interface EpochHeader { /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ @@ -183,18 +163,10 @@ interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] - /** - * The session prefix: request-only messages sent BEFORE the entire derived - * history (the `agent/session-prefix` waterfall's product, composed once - * per loop instance and reused for every request it sends). Not session - * history — `deriveMessages()` never returns it — so the header is its - * only durable record; absent when the instance composed none. - */ - messagePrefix?: Message[] } ``` -规范形式:空系统提示词、空工具列表和空会话前缀都表示为字段缺失,与请求构建方式一致。`messagePrefix` 是 `agent/session-prefix` waterfall(瀑布式事件)产物的持久记录(请求 = `messagePrefix + derived history`);每个 agent loop 实例只组合一次,并包含在该实例记录的每份完整快照中。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 +规范形式:空系统提示词和空工具列表都表示为字段缺失,与请求构建方式一致。包含已移除的 `request/header-delta` 事件或完整快照原因为 `fallback` 的旧版 v0 日志,会在 seed、append 和持久化加载边界被拒绝,而不会以不完整方式回放。 ## `SessionEvent`:一条日志条目 @@ -372,6 +344,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused @@ -472,9 +456,9 @@ declare class Session { `Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则: - `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。 -- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript。 +- `assistant/message` → 一条 assistant 消息,包含事件的提供方/模型溯源信息和可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 以承载用量和溯源信息,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。 - `tool/result` → 一条携带 `tool-result` 块的 user 消息。 -- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`。可选的 JSON `meta` 保留在事件日志中,绝不渲染。 +- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;溯源信息与领域数据都在其类型化的 source 中。 - `steering/message` → 按时间顺序在相应位置生成一条携带确切 `content` 的 user-role 消息;可选 envelope 仅作为日志中的展示元数据保留。 其余所有事件(`turn/*`、`step/*`、插件所有的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。操作错误的步骤号记录在 `turn/end.reason`(`kind: 'error'`)中;如果是最终模型请求失败,其中包含规范化的 `LlmFailure` 事实,其他实时错误则包含消息/代码。由于这一尚未发布的格式有意不提供兼容性承诺,seed/load 校验会拒绝缺少提供方和模型的请求头,以及缺少提供方/模型溯源信息的 assistant 消息,而不会猜测历史数据应走的提供方路由。 @@ -496,14 +480,12 @@ declare class Session { */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } + /** Recovery turn reopened over the repaired current session log. */ + retry: { kind: 'retry' } /** - * An out-of-band context injection (`agent.inject()`) made while the agent - * was idle. The loop wraps the injected `user/message` (a non-`user` source, - * plugin by default) in a one-shot turn (`turn/start` → `user/message` → - * `turn/end`) so every event in the log stays turn-enclosed — the - * durability/replay boundary is the turn, and a bare event between turns would - * otherwise be indistinguishable from a crash tail on reload. The trigger's - * `source` mirrors that message's producer. + * An out-of-band producer explicitly enclosed injected context in a one-shot + * turn. `Agent.inject()` appends idle context directly and does not use this + * trigger; the source mirrors the producer of the enclosed `user/message`. */ injection: { kind: 'injection'; source: MessageSource } } @@ -528,7 +510,8 @@ interface TurnEndReasonMap { * 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`). Final model-request failures retain their normalized facts - * as one `failure`; other turn failures retain their live Error message/code. + * as one `failure`; other thrown values retain their rendered message and a + * real `HarnessError` code when present. */ error: { kind: 'error'; step: number } & ( | { failure: LlmFailure; message?: never; code?: never } @@ -537,11 +520,6 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } - /** - * 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 } /** * A persistence backend closed a crash-orphaned turn on reload. The loop never * emits this marker, and the events recorded before the crash remain intact. @@ -550,7 +528,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`rejected` 表示一个零步骤轮次,其已认领的提示词被 `agent/prompt-submit` 钩子阻止(ACP(Agent Client Protocol)桥接层将其映射为 `cancelled`)。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 +`max-tokens` 与模型调用中同名的 `FinishReason` 对应:只要轮次内有任何步骤以 `max-tokens` 结束,整个轮次就以 `max-tokens` 而不是 `completed` 结束(即使之后继续执行,截断事实仍优先),让消费方能够区分正常停止和截断停止;但它只优先于 `completed`,`disposed`/`aborted`/`error` 结果的优先级更高。`interrupted` 是唯一不会由任何 loop 发出的原因:它由崩溃恢复合成(见 [persistence.md](persistence.md))。两个 map 均可通过合并扩展。 ## 轮次封闭不变式 @@ -560,7 +538,7 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史),但与所有事件一样,必须位于一个打开的轮次内。完整的逐事件枚举(核心与插件贡献的,含 payload 与溯源信息)见生成的[持久化日志事件目录](../persistence-catalog.md);压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 -钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录:它注入的 `user/message` 已是持久证据,而且当时没有已打开的轮次可容纳该记录(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 +钩子桥接层的 `hook/invoked` / `hook/result` 溯源对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。轮次中间的钩子点(`PreToolUse`/`PostToolUse`/`Stop`)在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 与轮次开始前的 `UserPromptSubmit` 准入 seam 都不生成 `hook/*` 记录,因为两者都没有已打开的轮次可容纳该记录;被放行的上下文改由其带来源的 `user/message` 作为持久证据(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性契约 diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..d4b20a7fbb 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -1,6 +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 -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +# pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md +skills.md: 2f47881ba5b694ab5affa43add60f340c4d17dbb +skills.zh.md: b5a212f81cc17975cb203738f2636bd5f5695f4f diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..2f47881ba5 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -47,6 +47,7 @@ The shipped local provider scans roots in rank order: | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | `Config.bundledSkillDir` when configured | The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. @@ -56,7 +57,7 @@ Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider ac ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## Summaries, candidates, and complete definitions @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound. ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +155,6 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` contributes a user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). +`dsh-tool-skill` injects a durable user-role `` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..b5a212f81c 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -47,6 +47,7 @@ interface SkillProvider { | 300 | `custom` | `Config.customSkillDirs` | | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | +| 600 | `bundled` | 配置了 `Config.bundledSkillDir` 时使用该目录 | 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 @@ -56,7 +57,7 @@ skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方 ```ts type-equiv /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ -type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) +type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {}) ``` ## 摘要、候选项与完整定义 @@ -142,7 +143,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`)。消费方拥有其目录描述上限。 ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +155,6 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 +`dsh-tool-skill` 在存活会话的第一个 `agent/step` 注入一条持久的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml new file mode 100644 index 0000000000..b85701557b --- /dev/null +++ b/docs/core-data-structures/subprocess.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 +subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2 +subprocess.zh.md: 5befdcdfc9b0e1d2a9adc825b177c90e53269def diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md new file mode 100644 index 0000000000..922e7ad0ee --- /dev/null +++ b/docs/core-data-structures/subprocess.md @@ -0,0 +1,239 @@ +# Subprocess + +English | [中文](subprocess.zh.md) + +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams and out-of-process backends — the [bash executor family](bash.md) (collect-mode batch output), the LSP host (piped protocol streams + a collected stderr tail), and the ACP subagent backend (piped protocol streams + inherited stderr). This seam owns the managed `DSH_*` environment namespace, the shared credential scrub (`scrubbedParentEnv`), and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports the vocabulary so bash consumers keep one import root. + +Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## Managed environment namespace and captured output + +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## Node-shaped stdio dispositions + +Each stream's disposition is explicit, chosen per consumer: raw pipes for protocol framing (LSP JSON-RPC, ACP ndjson), inherit for pass-through diagnostics, and collect mode for bounded batch output — with the spill file optional, so a diagnostic tail (a language server's stderr) buffers without leaving files behind. + +```ts type-equiv +/** + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## The fully-explicit spawn spec + +The seam applies no defaults: every disposition, limit, and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ + graceMs: number + /** + * Abort signal — starts the terminate escalation on the process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. + */ + env?: Record | undefined +} +``` + +## Handles: streams, readers, and tree-scoped termination + +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). + +```ts type-equiv +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. + */ +interface SubprocessHandle { + /** Process id (tree root); -1 when the spawn itself failed. */ + readonly pid: number + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + readonly done: Promise + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one collected output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). + */ +interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` + + +## Outcomes carry exit facts only + +`done` reports Node's close-event vocabulary and no cause classification — the service kills on abort but never decides why (the caller reads the deadline signal it owns, e.g. the bash executor's `timedOut`/`aborted` split). Collected output stays readable through `handle.collected` after settlement, so batch and streaming callers share one access path. + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null +} +``` + +## Service behavior + +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached trees, per-disposition wiring, credential scrub, terminate-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md new file mode 100644 index 0000000000..5befdcdfc9 --- /dev/null +++ b/docs/core-data-structures/subprocess.zh.md @@ -0,0 +1,239 @@ +# 进程管理器 + +[English](subprocess.md) | 中文 + +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 + +源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## 受管环境命名空间与捕获的输出 + +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## Node 形状的 stdio 处置方式(disposition) + +每条流的处置方式都显式给出,由各消费方自行选择:原始管道用于协议分帧(LSP JSON-RPC、ACP ndjson),inherit 用于直通的诊断输出,收集模式用于有界的批量输出;其中 spill 文件是可选的,因此诊断尾部(语言服务器的 stderr)可以只在内存中缓冲,不留下任何文件。 + +```ts type-equiv +/** + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ + graceMs: number + /** + * Abort signal — starts the terminate escalation on the process tree when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. + */ + env?: Record | undefined +} +``` + +## 句柄:流、读取器与以进程树为范围的终止 + +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 + +```ts type-equiv +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. + */ +interface SubprocessHandle { + /** Process id (tree root); -1 when the spawn itself failed. */ + readonly pid: number + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + readonly done: Promise + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * Cursor-free incremental access to one collected output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). + */ +interface SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` + + +## 结果只承载退出事实 + +`done` 报告 Node close 事件的词汇,不携带原因分类:服务会在中止时终止进程,但绝不判定原因(调用方读取归自己所有的 deadline 信号,例如 bash 执行器的 `timedOut`/`aborted` 拆分)。收集到的输出在结算后仍可经 `handle.collected` 读取,因此批量与流式调用方共用一条访问路径。 + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null +} +``` + +## 服务行为 + +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程树、按处置方式接线的流、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 7c82b890b7..75a4199f18 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -tools.md: 250e869397f8ecb128d5b644ff7506376d0657c6 -tools.zh.md: 96fc9d3eeda0240e195beb11bea088d5606d4757 +tools.md: 65b1d398238d3779def303d2d3a36bd641778bd7 +tools.zh.md: 2fe3eb3dbca2447cfc06da25a930de92aae34898 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 250e869397..65b1d39823 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -215,7 +215,16 @@ interface ToolRunContext extends ToolExecution { * the agent loop. Contexts retain their individual source and metadata and * are emitted in call order. */ - deferContext(context: HookContext): void + deferContext(context: UserMessageData): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void } ``` @@ -320,7 +329,9 @@ interface ToolExecutionSuccess { readonly content: ContentBlock[] readonly error?: never readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true } ``` @@ -332,7 +343,8 @@ interface ToolExecutionFailure { readonly value?: never readonly content: ContentBlock[] readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + readonly concludesTurn?: never } ``` @@ -368,9 +380,9 @@ type PreToolDecision = * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] } ``` Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 96fc9d3eed..2fe3eb3dbc 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -215,7 +215,16 @@ interface ToolRunContext extends ToolExecution { * the agent loop. Contexts retain their individual source and metadata and * are emitted in call order. */ - deferContext(context: HookContext): void + deferContext(context: UserMessageData): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void } ``` @@ -320,7 +329,9 @@ interface ToolExecutionSuccess { readonly content: ContentBlock[] readonly error?: never readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true } ``` @@ -332,7 +343,8 @@ interface ToolExecutionFailure { readonly value?: never readonly content: ContentBlock[] readonly meta?: JsonValue - readonly additionalContexts?: HookContext[] + readonly additionalContexts?: UserMessageData[] + readonly concludesTurn?: never } ``` @@ -368,9 +380,9 @@ type PreToolDecision = * next request, or block by turning corrective feedback into an error result. */ type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] } ``` 调用 `next()` 获取默认决策,或直接返回一个决策以短路。前置策略可以 deny 或 ask;只有 `allowed-once` 才继续执行,而未授权、缺少审批通道或服务、或无 agent 的请求都会变为拒绝。Guard 仍可施加最终拒绝。参数不可被改写,因为历史记录、审计、UI 和执行必须保持一致。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8d19bd9880..74d9dfbea5 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 docs/development.md -development.md: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f -development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780 +development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca +development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc diff --git a/docs/development.md b/docs/development.md index fd7f39ae7b..32339fa2af 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,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. +- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension. - Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests. ## First-time setup @@ -19,14 +19,20 @@ Install dependencies from the repo root: pnpm install ``` -The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands. +The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md). If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly. + +Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. + +After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract. + Run typecheck once after a fresh clone: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 7dd6209bad..c74a813466 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -8,7 +8,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。 +- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。 - 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。 ## 首次搭建 @@ -19,14 +19,20 @@ pnpm install ``` -安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。 +安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。 如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装: ```sh -pnpm exec lefthook install --force +node scripts/install-lefthook.mjs ``` +包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。 + +启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 + +检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。 + 新克隆后请先运行一次类型检查: ```sh diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d042a9e17a..269c972e47 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,37 +7,34 @@ 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:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../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:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:424`](../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:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:474`](../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), [`plan-mode`](../packages/plan/plan-mode) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:381`](../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-start` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:410`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../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:71`](../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:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:56`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -48,6 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index cc40473c49..a6862f6631 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -64,7 +64,6 @@ flowchart TD pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] - pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] end subgraph group_web["packages/web"] @@ -213,11 +212,19 @@ flowchart TD pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] end + subgraph group_subprocess["packages/subprocess"] + pkg_subprocess["subprocess"] + pkg_subprocess_local["subprocess-local"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tasks_local["tasks-local"] pkg_tool_tasks["tool-tasks"] end + subgraph group_telemetry["packages/telemetry"] + pkg_session_telemetry["session-telemetry"] + pkg_session_telemetry_otel["session-telemetry-otel"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -233,7 +240,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -250,8 +256,10 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants + pkg_subprocess --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -281,6 +289,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants + pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths @@ -290,6 +299,8 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -346,6 +357,7 @@ flowchart TD pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -400,6 +412,7 @@ flowchart TD pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm @@ -420,6 +433,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -491,6 +505,9 @@ flowchart TD pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session + pkg_session_telemetry --> pkg_agent + pkg_session_telemetry --> pkg_invariants + pkg_session_telemetry --> pkg_session pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_invariants @@ -565,10 +582,15 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_pty_local --> pkg_subprocess pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -702,6 +724,7 @@ flowchart TD pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants @@ -728,7 +751,7 @@ flowchart TD pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_subprocess + pkg_subagent_acp --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -867,7 +890,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -884,7 +906,8 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -893,11 +916,12 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -912,7 +936,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -928,12 +952,12 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | @@ -952,6 +976,7 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | @@ -965,8 +990,9 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | -| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`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) | @@ -988,11 +1014,11 @@ flowchart TD | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c039792ee8..3336676eac 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:398`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) ## Events @@ -150,7 +150,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -166,7 +166,7 @@ Source: [`packages/core/session/src/types.ts:270`](../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:277`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `compact/*` @@ -271,14 +271,26 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `llm/retry` — log-only ```ts persistence-catalog -/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ +/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ 'llm/retry': { turn: number step: number + provider: string + mode: 'normal' + policyKey: string retry: number maxRetries: number delayMs: number failure: LlmFailure +} | { + turn: number + step: number + provider: string + mode: 'always' + policyKey: string + retry: number + delayMs: number + failure: LlmFailure } ``` @@ -315,22 +327,6 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/src/index.ts) -### `prompt/*` - -#### `prompt/blocked` — log-only - -```ts persistence-catalog -/** - * 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. - */ -'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } -``` - -Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) - -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) - ### `request/*` #### `request/header` — log-only @@ -343,7 +339,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -377,7 +373,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -396,10 +392,10 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages ```ts persistence-catalog /** Steering content injected between steps of a running turn. */ -'steering/message': PromptMessageData & { turn: number } +'steering/message': UserMessageData & { turn: number } ``` -Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) ### `step/*` @@ -410,7 +406,7 @@ Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -419,7 +415,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) ### `todo/*` @@ -432,7 +428,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `tool/*` @@ -449,7 +445,7 @@ Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -526,7 +522,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../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:295`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) ### `turn/*` @@ -544,23 +540,20 @@ Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) #### `turn/start` — log-only ```ts persistence-catalog /** - * 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). + * Opens turn `turn`. `trigger` records what started the model loop. */ 'turn/start': { turn: number; trigger: TurnTrigger } ``` Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) ### `user/*` @@ -572,11 +565,10 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ * (the queued message claimed for this turn), a synthetic `agent.inject()` * context (file-change notices, subdir AGENTS.md, skill content, cron * notifications, …), or an admitted goal continuation round. All three - * project their `content` verbatim; `source` (with a non-`user` kind marking - * injected context) is the only channel that tells them apart. An idle - * injection wraps this event in a one-shot turn so the log stays turn-enclosed. + * project their `content` verbatim; `source` tells them apart. An idle + * injection may append this event between turns without running the model. */ -'user/message': PromptMessageData +'user/message': UserMessageData ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 311791e594..995a8c669d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@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 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are 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-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -207,7 +207,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `cordis_inspect` -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. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. +Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. ```json { @@ -220,7 +220,7 @@ Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: "services", "plugins", "tools", - "dynamic", + "temporary", "api", "events" ] @@ -237,7 +237,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_mount` -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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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. +Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. ```json { @@ -245,7 +245,7 @@ Mount a NEW cordis plugin into the live runtime that is running THIS agent (self "properties": { "code": { "type": "string", - "description": "Body of an async JS function; must `return` the plugin to mount." + "description": "JavaScript body returning a temporary Plugin; evaluated now and saved nowhere." } }, "required": [ @@ -258,7 +258,7 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor ### `cordis_unmount` -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). +Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. ```json { @@ -266,7 +266,7 @@ Dispose a plugin previously mounted with cordis_mount, by id. All its registrati "properties": { "id": { "type": "string", - "description": "The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")." + "description": "The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart." } }, "required": [ @@ -277,7 +277,7 @@ Dispose a plugin previously mounted with cordis_mount, by id. All its registrati Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes. +Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index 9704eff7c5..b20764a24d 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.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 -events.md: 0c57681a55ea0200fe8f33293176fc94f09a4ce5 -events.zh.md: 3e14739d4a97ba014d545c9f226000507aaeacef +events.md: 5cd5d22f854d0b4e271e892cbdb1ccebe687ae49 +events.zh.md: 5fd4d5de53897e32523ab478626965ad7c9602ba diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 0c57681a55..5cd5d22f85 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## 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. +Harness Cordis events use `namespace/action` names, including `agent/step`, `agent/request`, `agent/request-error`, `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`. diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 3e14739d4a..5fd4d5de53 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -101,7 +101,7 @@ declare module 'cordis' { ## 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/step`、`agent/request`、`agent/request-error`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 diff --git a/eslint.config.mjs b/eslint.config.mjs index ef03904390..696b082828 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,7 +27,13 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/*/src/**/*.ts', 'apps/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], + files: [ + 'packages/*/*/src/**/*.{ts,tsx}', + 'apps/*/src/**/*.{ts,tsx}', + 'examples/**/*.{ts,tsx}', + 'scripts/**/*.{ts,tsx}', + 'website/**/*.{ts,tsx}', + ], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -80,7 +86,12 @@ export default tseslint.config( // --- tests: same rules, minus the friction that fights test ergonomics -- { - files: ['packages/*/*/tests/**/*.ts', 'apps/*/tests/**/*.ts', 'examples/*/tests/**/*.ts', 'scripts/**/*.spec.ts'], + files: [ + 'packages/*/*/tests/**/*.{ts,tsx}', + 'apps/*/tests/**/*.{ts,tsx}', + 'examples/*/tests/**/*.{ts,tsx}', + 'scripts/**/*.spec.{ts,tsx}', + ], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -117,7 +128,10 @@ export default tseslint.config( // Context merges collide), so the shared project service cannot resolve // them — parse these through the client aggregate explicitly. { - files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'], + files: [ + 'packages/client/*/tests/**/*.{ts,tsx}', + 'scripts/client-bundle-purity.spec.ts', + ], languageOptions: { parserOptions: { projectService: false, @@ -129,7 +143,7 @@ export default tseslint.config( // --- file-local duplication (all owned TypeScript) --------------------- { - files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], + files: ['packages/**/*.{ts,tsx}', 'apps/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'scripts/**/*.{ts,tsx}', 'website/**/*.{ts,tsx}'], plugins: { sonarjs }, rules: { // Cross-file clones are covered separately by jscpd. @@ -146,7 +160,14 @@ export default tseslint.config( // --- formatting (everything we own) ------------------------------------- { - files: ['packages/**/*.ts', 'apps/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], + files: [ + 'packages/**/*.{ts,tsx}', + 'apps/**/*.{ts,tsx}', + 'examples/**/*.{ts,tsx}', + 'scripts/**/*.{ts,tsx}', + 'website/**/*.{ts,tsx}', + 'eslint.config.mjs', + ], plugins: { '@stylistic': stylistic }, rules: { '@stylistic/indent': ['error', 2], diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index a96133c3c8..f5b73d9b6d 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -1,6 +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 -README.md: 8fd261e624771dc568582b6d22e9985072a06715 -README.zh.md: 2ff0d0bef382435588fce01c23aa7b74e1a149b5 +# pnpm run verify-translation-pairing --write examples/README.md +README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f +README.zh.md: 72ab92602d0a53cabdbfa8bc34838061df25d1c7 diff --git a/examples/README.md b/examples/README.md index 8fd261e624..7f12178d1b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,9 +22,9 @@ An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foregr ## cordis-agent -The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. +The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. -Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. +Run the TUI with `pnpm run demo:cordis`, the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis web`, or the ACP server with `pnpm run demo:cordis acp` (all need `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. ## acp-agent diff --git a/examples/README.zh.md b/examples/README.zh.md index 2ff0d0bef3..72ab92602d 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -22,9 +22,9 @@ ## cordis-agent -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查自身所在的实时 cordis 运行时,将模型编写的插件挂载到其中(事件监听器、一个专为自身创建的全新工具,或一个供另一挂载项注入的服务),并再次释放它们。所有动态挂载都归入同一 `cordis-dynamic` fiber 子树。`ctx.fs`/`ctx.web` 服务仅作为提供方随行,是这些插件构建所依赖的能力。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 -运行:`pnpm run demo:cordis`(需要 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## acp-agent diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 8d112f2910..b4a3236920 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,6 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy + plugin_acp_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_acp_subprocess plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -68,6 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis-tools.cordis.yml b/examples/acp-agent/cordis-tools.cordis.yml new file mode 100644 index 0000000000..c6e3e84457 --- /dev/null +++ b/examples/acp-agent/cordis-tools.cordis.yml @@ -0,0 +1,10 @@ +# Add the self-referential Cordis tools without changing the base ACP tool +# presentation mode. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index fa7f6ad4ef..f784972429 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,6 +33,10 @@ mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" workspaceRoot: !!js process.cwd() +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: diff --git a/examples/acp-agent/retry.cordis.snapshot.yml b/examples/acp-agent/retry.cordis.snapshot.yml index 4d7010f774..883858cea1 100644 --- a/examples/acp-agent/retry.cordis.snapshot.yml +++ b/examples/acp-agent/retry.cordis.snapshot.yml @@ -1,8 +1,7 @@ # Keyless replay for the retry overlay: disable the key-requiring DeepSeek -# adapter, insert `llm-replay`, and restate the app config with the same -# deterministic 1 ms zero-jitter retry policy as the live sibling. A config -# patch replaces the whole app config, so the base fields are restated -# verbatim (raw JSONL persistence so the harness can harvest the log). +# adapter, insert `llm-replay`, and give its provider the same deterministic +# 1 ms zero-jitter retry policy as the live sibling. The app patch still +# restates its whole config for raw JSONL persistence and the recorded model. - id: base name: '@cordisjs/plugin-include' config: @@ -20,11 +19,6 @@ persistenceCompression: none workspaceContext: maxBytes: 65536 - llmRetry: - maxTransientRetries: 2 - initialDelayMs: 1 - maxDelayMs: 1 - jitterRatio: 0 persona: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. @@ -36,6 +30,13 @@ providers: - id: deepseek name: DeepSeek + retryPolicy: + mode: normal + maxRetries: 2 + backoff: + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 models: - id: deepseek-v4-flash - id: deepseek-v4-pro diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index bbb3f81c0d..57e364a694 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -2,14 +2,32 @@ # deterministic 1 ms zero-jitter delay so the durable `llm/retry` event # (`delayMs`) and replay wall time stay reproducible. The overlay changes no # tool or prompt composition, so its scenarios share the default header class. -# A config patch replaces the whole app config, so the base fields are restated -# verbatim; the model is re-pinned to `deepseek-v4-flash` like the other -# snapshot overlays because the recorded corpus was captured on flash. +# Config patches replace whole plugin configs: the provider patch restates its +# adapter fields around `retryPolicy`, while the app patch re-pins the recorded +# flash model and restates its base fields. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max + defaultContextWindow: 256000 + retryPolicy: + mode: normal + maxRetries: 2 + backoff: + initialDelayMs: 1 + maxDelayMs: 1 + jitterRatio: 0 + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: @@ -19,11 +37,6 @@ persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" workspaceContext: maxBytes: 65536 - llmRetry: - maxTransientRetries: 2 - initialDelayMs: 1 - maxDelayMs: 1 - jitterRatio: 0 persona: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4fb6f9493b..5d8f4669bb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -131,10 +131,10 @@ const SCENARIOS: Scenario[] = [ { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, // Keyless, authored (like error-finish): a live provider cannot be coaxed // into a degenerate empty completion, so the fixture scripts the adapters' - // EMPTY_RESPONSE error finish (step 1) followed by the recovered reply - // (step 2), proving the default retry policy end to end: the durable + // EMPTY_RESPONSE error finish in turn 1 followed by the recovered reply + // in retry turn 2, proving the default retry policy end to end: the durable // llm/retry event, no ACP output for the discarded attempt, the recovered - // reply, and a clean completed turn. Its overlay only pins a deterministic + // reply, and a clean completed retry turn. Its overlay only pins a deterministic // 1 ms zero-jitter delay, so it shares the default header class. { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG }, // Keyless, authored (like error-finish/cancel): deterministically forcing a @@ -195,10 +195,10 @@ const SCENARIOS: Scenario[] = [ headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, - // Prompt-submit blocks are authored keylessly: they persist a rejected turn - // and hook events without starting a model step, so their logs still compare. - { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, - { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, + // Prompt-submit blocks are authored keylessly. Admission rejects before a + // turn opens, so only the ACP stop reason is observable and no log is harvested. + { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false }, + { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false }, // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). // SessionStart/SubagentStart are excluded because detached injection races log diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml index 3bd5f5393c..c69cf574f7 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -10,6 +10,10 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' +# The out-of-process ACP backend spawns its child through the subprocess seam. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: subagent-acp name: '@deepseek-ai/dsh-subagent-acp' config: diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index d598b34e37..efeb40d2ab 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -12,7 +12,7 @@ {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal-session snapshot proof\",\"max_goal_rounds\":2}"}} {"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} +{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal-session snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"} {"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -49,6 +49,6 @@ {"type":"step/start","seq":47,"time":0,"data":{"turn":3,"step":1}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"user/message","seq":50,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":52,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} +{"type":"step/end","seq":50,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":51,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} +{"type":"user/message","seq":52,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json index b66945d9a7..f170f78a75 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "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." } + { "op": "prompt", "text": "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK." } ] } diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index ffb8382e0e..aae2454dbd 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,6 +1,6 @@ {"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":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -11,19 +11,19 @@ {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":22,"time":1785036891166,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}} -{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":22,"time":1785036891166,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":23,"time":1785036891167,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785036891170,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785036891171,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785036891175,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1785036891796,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1785036891796,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1785036891798,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785036891799,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785036891801,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3a37f3da6a..58a54a1509 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -56,21 +56,21 @@ interface ToolArgsMap { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; } & Record; - /** 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. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ + /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ cordis_inspect: { /** Limit the report to one section. Omit for all sections. */ - what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; + what?: "services" | "plugins" | "tools" | "temporary" | "api" | "events"; /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record; - /** 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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. */ + /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */ cordis_mount: { - /** Body of an async JS function; must `return` the plugin to mount. */ + /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */ code: string; } & Record; - /** 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). */ + /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */ cordis_unmount: { - /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ + /** The temporary Plugin id returned by cordis_mount (for example "dyn-1"); valid only in this process and invalid after unmount or restart. */ id: string; } & Record; /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 1abccfd566..314b24e2be 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -47,7 +47,7 @@ }, { "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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", + "description": "Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", "parameters": { "type": "object", "properties": { @@ -58,7 +58,7 @@ "services", "plugins", "tools", - "dynamic", + "temporary", "api", "events" ] @@ -72,13 +72,13 @@ }, { "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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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.", + "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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." + "description": "JavaScript body returning a temporary Plugin; evaluated now and saved nowhere." } }, "required": [ @@ -88,13 +88,13 @@ }, { "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).", + "description": "Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.", "parameters": { "type": "object", "properties": { "id": { "type": "string", - "description": "The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")." + "description": "The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart." } }, "required": [ diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 833ed36355..d291e1b180 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5747fa727e10/57c2f8c3fbf2-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-ee77dff02/session-5e53dc8acfe4/2ce9d7a31a38-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} 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 95023a0ed8..cbb154f49b 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 @@ -2,32 +2,33 @@ {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"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":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785014475034,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785014475035,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785014475456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785014475457,"data":{"turn":1,"step":1,"index":0,"dt":[139,42,1,0,0,0,40,1,0,0,0,43,0,0,0,39,43,1,0,0,0,40,1,40,0,0,1,42,0,1,0,0,40,0,0,1,0,0,44,0,1,39,0,1,0],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," `","n","ested","/t","ask",".txt","`"," using"," a"," `","run","_code","`"," program",","," and"," then"," answer"," the"," question"," \"","What"," is"," the"," Code"," Mode"," workspace"," hand","shake","?\""," based"," on"," the"," contents"," of"," that"," file","."]}} -{"type":"assistant/chunk","seq":52,"time":1785014476183,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":53,"time0":1785014476183,"data":{"turn":1,"step":1,"index":1,"dt":[41,1,0,0,41,0,0,1,41,1,0,0,40,0,42,0,0,0,1,0,40,1,0,0,0,0,41,0,0,42,1,0,41,1,0,0,42,0,0,0,0,41],"id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," result"," ="," await"," tools",".read","({"," file","_path",":"," \\\"","n","ested","/t","ask",".txt","\\\""," });\\n","return"," result",";\\n","\"",", ","\"","description","\"",": ","\"","Read"," nested","/t","ask",".txt","\"","}"]}} -{"type":"assistant/chunk","seq":96,"time":1785014476731,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."}}}} -{"type":"assistant/chunk","seq":97,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":98,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}}}} -{"type":"assistant/chunk","seq":99,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":100,"time":1785014476736,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"tool/call","seq":101,"time":1785014476737,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":102,"time":1785014476837,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":103,"time":1785014476842,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/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":104,"time":1785014476847,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false},"sourceEventSeqs":[101],"surfaceOp":"append"} -{"type":"user/message","seq":105,"time":1785014476847,"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\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":106,"time":1785014476850,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":107,"time":1785014476854,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":108,"time":1785014477311,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":109,"time0":1785014477311,"data":{"turn":1,"step":2,"index":0,"dt":[108,56,1,0,26,0,0,42,0,43,1,42,1,0,0,0,0,42,0,0,0,1,0,40,0,0,1,0,0,43],"texts":["The"," nested","/","AG","ENTS",".md"," file"," provides"," the"," instruction",":"," when"," asked"," for"," the"," Code"," Mode"," workspace"," hand","shake",","," answer"," exactly"," `","CODE","_M","ODE","_CONT","EXT","_OK","`."]}} -{"type":"assistant/chunk","seq":140,"time":1785014477799,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":141,"time0":1785014477799,"data":{"turn":1,"step":2,"index":1,"dt":[43,40,0,0,0,1,42,0,0,1,0,0,41,0],"texts":["**","Code"," Mode"," workspace"," hand","shake",":**"," `","CODE","_M","ODE","_CONT","EXT","_OK","`"]}} -{"type":"assistant/chunk","seq":156,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."}}}} -{"type":"assistant/chunk","seq":157,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} -{"type":"assistant/chunk","seq":158,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":159,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":160,"time":1785014477968,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":161,"time":1785014477972,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":162,"time":1785014477972,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1785122256262,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nWorkspace snapshot root instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2119a7072358cc727f8d9c4cb7388e905b075fe6"}]}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1785122256264,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122256265,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785014475457,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1785014475596,"data":{"turn":1,"step":1,"index":0,"dt":[42,1,0,0,0,40,1,0,0,0,43,0,0,0,39,43,1,0,0,0,40,1,40,0,0,1,42,0,1,0,0,40,0,0,1,0,0,44,0,1,39,0,1,0,126],"texts":["The"," user"," wants"," me"," to"," read"," the"," file"," `","n","ested","/t","ask",".txt","`"," using"," a"," `","run","_code","`"," program",","," and"," then"," answer"," the"," question"," \"","What"," is"," the"," Code"," Mode"," workspace"," hand","shake","?\""," based"," on"," the"," contents"," of"," that"," file","."]}} +{"type":"assistant/chunk","seq":53,"time":1785014476183,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":54,"time0":1785014476224,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,41,0,0,1,41,1,0,0,40,0,42,0,0,0,1,0,40,1,0,0,0,0,41,0,0,42,1,0,41,1,0,0,42,0,0,0,0,41,89],"id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","args":["","{","\"","code","\"",": ","\"","\\n","const"," result"," ="," await"," tools",".read","({"," file","_path",":"," \\\"","n","ested","/t","ask",".txt","\\\""," });\\n","return"," result",";\\n","\"",", ","\"","description","\"",": ","\"","Read"," nested","/t","ask",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":97,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."}}}} +{"type":"assistant/chunk","seq":98,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":99,"time":1785014476732,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}}}} +{"type":"assistant/chunk","seq":100,"time":1785122256269,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":101,"time":1785122256269,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":102,"time":1785122256269,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} +{"type":"tool/code-dispatch-start","seq":103,"time":1785122256332,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/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":105,"time":1785122256338,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"user/message","seq":106,"time":1785122256338,"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":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":107,"time":1785122256338,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":108,"time":1785122256347,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":109,"time":1785014477311,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":110,"time0":1785014477419,"data":{"turn":1,"step":2,"index":0,"dt":[56,1,0,26,0,0,42,0,43,1,42,1,0,0,0,0,42,0,0,0,1,0,40,0,0,1,0,0,43,41],"texts":["The"," nested","/","AG","ENTS",".md"," file"," provides"," the"," instruction",":"," when"," asked"," for"," the"," Code"," Mode"," workspace"," hand","shake",","," answer"," exactly"," `","CODE","_M","ODE","_CONT","EXT","_OK","`."]}} +{"type":"assistant/chunk","seq":141,"time":1785014477799,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":142,"time0":1785014477842,"data":{"turn":1,"step":2,"index":1,"dt":[40,0,0,0,1,42,0,0,1,0,0,41,0,0],"texts":["**","Code"," Mode"," workspace"," hand","shake",":**"," `","CODE","_M","ODE","_CONT","EXT","_OK","`"]}} +{"type":"assistant/chunk","seq":157,"time":1785014477967,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."}}}} +{"type":"assistant/chunk","seq":158,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} +{"type":"assistant/chunk","seq":159,"time":1785014477968,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":160,"time":1785122256351,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":161,"time":1785122256351,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The nested/AGENTS.md file provides the instruction: when asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK`."},{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":226,"outputTokens":47,"cacheReadTokens":6272,"reasoningTokens":31}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1785122256351,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":163,"time":1785122256351,"data":{"turn":1,"reason":{"kind":"completed"}}} 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 e51933cdf3..49fb484c8d 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"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":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"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 - tool schema, execution, and optional finalization/presentation callbacks.\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,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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 followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\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 EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\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 type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n 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 }\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 placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\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 interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n 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': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\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?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\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 interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: 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 readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\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 interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\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 readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\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?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\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 }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\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 }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"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 - tool schema, execution, and optional finalization/presentation callbacks.\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,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\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 acceptsNextStep: boolean;\n readonly ctx: Context;\n send(input: UserMessageData, options: SendOptions): AgentMessageId;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(input: UserMessageData): AgentMessageId;\n steer(input: UserMessageData): AgentMessageId;\n inject(input: UserMessageData): AgentMessageId;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\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 EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\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 type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n 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 }\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 JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\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 ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n 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': UserMessageData;\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?: JsonValue;\n };\n 'steering/message': UserMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n 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 }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\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 interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: 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 readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\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 interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: never;\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 readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessageData[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\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?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessageData): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\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 interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessageData {\n content: ContentBlock[];\n source: MessageSource;\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index f164c7fe62..8082fa5cca 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -7,13 +7,15 @@ {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} {"type":"step/end","seq":7,"time":0,"data":{"turn":1,"step":1}} -{"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"step/start","seq":9,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"turn/end","seq":9,"time":1785047244285,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}} +{"type":"turn/start","seq":10,"time":1785047244285,"data":{"turn":2,"trigger":{"kind":"retry"}}} +{"type":"step/start","seq":11,"time":1785047244289,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":15,"time":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785047244294,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785047244294,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785047244294,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":19,"time":1785047244294,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 602e5aeb0d..d6afb1af3d 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1784821261753,"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\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1784821261754,"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":130,"time":1784821261758,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","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":131,"time":1784821261759,"data":{"id":"8e278621-09a9-4c76-a785-74aea76cc120","outcome":"allowed-once"}} +{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"2c0f15e6-3b6e-45b8-b5df-440ea83ebee9","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":131,"time":1784821261759,"data":{"id":"2c0f15e6-3b6e-45b8-b5df-440ea83ebee9","outcome":"allowed-once"}} {"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}} {"type":"step/start","seq":134,"time":1784821261782,"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 e2970ebad1..dca5d59950 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -14,8 +14,8 @@ {"type":"assistant/chunk","seq":151,"time":1783860681967,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":152,"time":1784821263293,"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\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":153,"time":1784821263294,"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":154,"time":1784821263300,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","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":155,"time":1784821263301,"data":{"id":"f55fc10d-f2f1-435f-8c85-076bafaa5f85","outcome":"rejected"}} +{"type":"approval/asked","seq":154,"time":1784821263300,"data":{"id":"8547967e-97b5-4b73-a553-0d82b1ec6652","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":155,"time":1784821263301,"data":{"id":"8547967e-97b5-4b73-a553-0d82b1ec6652","outcome":"rejected"}} {"type":"tool/result","seq":156,"time":1784821263302,"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":[153],"surfaceOp":"append"} {"type":"step/end","seq":157,"time":1784821263307,"data":{"turn":1,"step":1}} {"type":"step/start","seq":158,"time":1784821263307,"data":{"turn":1,"step":2}} 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 5b32c37cb6..f5597e6855 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -14,9 +14,9 @@ {"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":86,"time":1784821264893,"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\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":87,"time":1784821264893,"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":88,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"d1a35247-af6d-4df7-9c62-e53d1be0a3e7","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"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,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","outcome":"allowed-once"}} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} 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 76a172d5cf..c05f0d9bbe 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 @@ -16,8 +16,8 @@ {"type":"tool/call","seq":54,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":55,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":56,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"8a5510f4-93b7-4e70-b082-20d13dec386d","outcome":"rejected"}} +{"type":"approval/asked","seq":57,"time":1783962235813,"data":{"id":"f54e812d-1b78-4813-93d6-91dd384905f7","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":58,"time":1783962235813,"data":{"id":"f54e812d-1b78-4813-93d6-91dd384905f7","outcome":"rejected"}} {"type":"tool/result","seq":59,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":60,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":61,"time":1783962235814,"data":{"turn":1,"step":2}} 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 dd6745b770..3d6badf9da 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,6 +1 @@ {"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}} -{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by policy hook"}} -{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by policy hook"}}} 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 ce959c93d7..8b48c62b32 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,21 +1,19 @@ {"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}} -{"type":"user/message","seq":3,"time":1783352160564,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1783352160564,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783352160564,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783352160565,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783352160566,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783352161228,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352161229,"data":{"turn":1,"step":1,"index":0,"dt":[106,28,0,29,0,0,1,0,27,1,0,0,28,0,0,28,1,0],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":29,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":30,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":31,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} -{"type":"assistant/chunk","seq":32,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":33,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":34,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1783352161515,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1783352161516,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1783352161516,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1785122243327,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":1785122243354,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122243354,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785122243359,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1783352160565,"data":{"turn":1,"step":1,"index":0,"dt":[1,662,1,106,28,0,29,0,0,1,0,27,1,0,0,28,0,0],"texts":["The"," user","'s"," favorite"," color"," is"," te","al",","," as"," stated"," in"," the"," context"," provided"," by"," the"," plugin","."]}} +{"type":"assistant/chunk","seq":26,"time":1783352161477,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":28,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":29,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} +{"type":"assistant/chunk","seq":30,"time":1783352161478,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":31,"time":1783352161511,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":32,"time":1783352161512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785122243360,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user's favorite color is teal, as stated in the context provided by the plugin."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2892,"outputTokens":22,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785122243360,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":35,"time":1785122243360,"data":{"turn":1,"reason":{"kind":"completed"}}} 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 126a761309..3d6badf9da 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,6 +1 @@ {"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}} -{"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by codex policy hook"}} -{"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by codex policy hook"}}} 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 4ba2fcf5e1..a0edc4ec28 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,21 +1,19 @@ {"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}} -{"type":"user/message","seq":3,"time":1783352209707,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"user/message","seq":4,"time":1783352209707,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1783352209707,"data":{"title":"What is my favorite color?","messageSeqs":[3],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":6,"time":1783352209709,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":7,"time":1783352209710,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":8,"time":1783352210353,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1783352210353,"data":{"turn":1,"step":1,"index":0,"dt":[117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0,0,0,1],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} -{"type":"assistant/chunk","seq":47,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":48,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} -{"type":"assistant/chunk","seq":49,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} -{"type":"assistant/chunk","seq":50,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} -{"type":"assistant/chunk","seq":51,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} -{"type":"assistant/chunk","seq":52,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} -{"type":"assistant/chunk","seq":53,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":54,"time":1783352210790,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":55,"time":1783352210790,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":56,"time":1783352210790,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":1785122250006,"data":{"title":"What is my favorite color?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":1785122250036,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1785122250036,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1785122250040,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":7,"time0":1783352209709,"data":{"turn":1,"step":1,"index":0,"dt":[1,643,0,117,31,26,28,1,0,0,0,29,0,0,27,1,0,27,1,27,0,1,0,0,28,0,0,0,29,0,0,1,0,0,27,0,0],"texts":["The"," user"," asked"," about"," their"," favorite"," color",","," and"," the"," context"," tells"," me"," they"," previously"," stated"," it","'s"," te","al","."," They"," asked"," me"," to"," reply"," with"," just"," the"," color"," and"," stop",","," without"," using"," any"," tools","."]}} +{"type":"assistant/chunk","seq":45,"time":1783352210754,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1783352210754,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"te"}}} +{"type":"assistant/chunk","seq":47,"time":1783352210755,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"al"}}} +{"type":"assistant/chunk","seq":48,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} +{"type":"assistant/chunk","seq":49,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"teal"}}}} +{"type":"assistant/chunk","seq":50,"time":1783352210787,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}}}} +{"type":"assistant/chunk","seq":51,"time":1783352210788,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":52,"time":1785122250042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."},{"type":"text","text":"teal"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2891,"outputTokens":41,"cacheReadTokens":0,"reasoningTokens":38}},"sourceEventSeqs":[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":"step/end","seq":53,"time":1785122250043,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":54,"time":1785122250043,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 26fd2bf5c1..3c68ea4aba 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1785167612540,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36007 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-0a508d3a5c8b/adedf3ca051a-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 0881d11f46..36f5202825 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,29 +2,30 @@ {"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":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783654655608,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","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":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} -{"type":"assistant/chunk","seq":7,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":8,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} -{"type":"assistant/chunk","seq":9,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} -{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} -{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1783654655609,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":15,"time":1783654655610,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1783654655610,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1783654655610,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} -{"type":"assistant/chunk","seq":20,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} -{"type":"assistant/chunk","seq":22,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} -{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} -{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":1783654655611,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[18,19,20,21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1783654655611,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":1783654655611,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1784903324926,"data":{"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"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1784903324927,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1784903324928,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":7,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} +{"type":"assistant/chunk","seq":8,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} +{"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1784903324935,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[6,7,8,9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1784903324936,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} +{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1784903324944,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1784903324952,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":20,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}} +{"type":"assistant/chunk","seq":21,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}} +{"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}} +{"type":"assistant/chunk","seq":24,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":25,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}} +{"type":"assistant/chunk","seq":26,"time":1784903324956,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":1784903324956,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[19,20,21,22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1784903324956,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1784903324956,"data":{"turn":1,"reason":{"kind":"completed"}}} 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 2270fc0845..f39968c84f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -18,7 +18,7 @@ {"type":"turn/start","seq":38,"time":1783352137162,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":39,"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":40,"time":1783352137163,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":41,"time":1783352137163,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"request/header","seq":41,"time":1785142305260,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":42,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":43,"time0":1783352137783,"data":{"turn":2,"step":1,"index":0,"dt":[178,28,31,26,0,0,0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} {"type":"assistant/chunk","seq":77,"time":1783352138275,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} @@ -26,7 +26,7 @@ {"type":"assistant/chunk","seq":82,"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":83,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} {"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":86,"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":[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],"surfaceOp":"append"} -{"type":"step/end","seq":87,"time":1783352138308,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":88,"time":1783352138308,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":86,"time":1785142305270,"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":[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],"surfaceOp":"append"} +{"type":"step/end","seq":87,"time":1785142305270,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":88,"time":1785142305270,"data":{"turn":2,"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 a664a20f76..dd63a6f0d2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -18,7 +18,7 @@ {"type":"turn/start","seq":32,"time":1783352147508,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":33,"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":34,"time":1783352147509,"data":{"turn":2,"step":1}} -{"type":"request/header","seq":35,"time":1783352147509,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"request/header","seq":35,"time":1785142306299,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","seq":36,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":37,"time0":1783352147925,"data":{"turn":2,"step":1,"index":0,"dt":[94,29,1,0,27,0,1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} {"type":"assistant/chunk","seq":68,"time":1783352148313,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} @@ -26,7 +26,7 @@ {"type":"assistant/chunk","seq":72,"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":73,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} {"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":75,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":76,"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":[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":"step/end","seq":77,"time":1783352148345,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":78,"time":1783352148345,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":76,"time":1785142306309,"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":[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":"step/end","seq":77,"time":1785142306309,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":78,"time":1785142306309,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 883e685a6d..fdb7dd59b5 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -2,24 +2,25 @@ {"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":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1783778297069,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783778297070,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}]}]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} -{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"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":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":1783778297070,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":12,"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":[11],"surfaceOp":"append"} -{"type":"user/message","seq":13,"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\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} -{"type":"step/end","seq":14,"time":1783778297072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":15,"time":1783778297072,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":16,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":1783778297073,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":1783778297073,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":23,"time":1783778297073,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]}},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":1784903339799,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":1784903339800,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_read","name":"read","argumentsDelta":"{\"file_path\":\"nested/task.txt\"}"}}} +{"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":1784903339801,"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":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} +{"type":"tool/result","seq":13,"time":1784903339813,"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":[12],"surfaceOp":"append"} +{"type":"user/message","seq":14,"time":1784903339813,"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":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]}},"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1784903339821,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1784903339822,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml index 7f4f2ae9dd..f9a03c46fa 100644 --- a/examples/cordis-agent/README.i18n.yaml +++ b/examples/cordis-agent/README.i18n.yaml @@ -1,6 +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 -README.md: 1309fe9b2935d3224f097ceb2e80501c8075a933 -README.zh.md: 2e3e7d7206d0d676ae7d9c9b3a2c2f8be26aafe7 +# pnpm run verify-translation-pairing --write examples/cordis-agent/README.md +README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 +README.zh.md: c2873b6de96a8b47ad8ea4fb2cf03a7501406300 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 1309fe9b29..55970e932b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.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). +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 lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it @@ -10,26 +10,28 @@ The self-referential harness demo: the DeepSeek V4 coding spine on the full-scre # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` The intended demo is staged — verify the listener link first, then let the agent extend itself: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. +> Unmount both temporary Plugins. [tool call] cordis_unmount({"id": "dyn-1"}) ``` -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer. +Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index 2e3e7d7206..c2873b6de9 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -10,26 +10,28 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. +> Unmount both temporary Plugins. [tool call] cordis_unmount({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..02d68215f5 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -3,7 +3,7 @@ # Cordis Agent App Composition -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it. +The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. ```mermaid flowchart LR @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek + plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_cordis_subprocess plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -39,6 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..83144b2153 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,9 +1,9 @@ # 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 +# service/plugin/tool/temporary/API/event state, mount a model-written temporary +# Plugin, and quiescently unmount it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. # Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; mounted code can reach live capabilities +# access but are not a security boundary; temporary Plugin code reaches live capabilities # such as `ctx.bash`. Grant this toolset like bash access. See # ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. @@ -25,6 +25,10 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -60,7 +64,7 @@ persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' + welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the {{model}} model. @@ -68,9 +72,11 @@ You run INSIDE a cordis plugin runtime, and your cordis_* tools operate on that live runtime: cordis_inspect to look around (its `api` and `events` sections document the service methods, type shapes, and events - your plugin code can use), cordis_mount to add a plugin (an event - listener, a brand-new tool for yourself, or a service other mounts - inject), cordis_unmount to clean one up. In mounted code, NEVER use Node + your Plugin code can use), cordis_mount to mount an in-memory temporary + Plugin (an event listener, a brand-new tool for yourself, or a service + another temporary Plugin injects), cordis_unmount to clean one up. These + Plugins remain across turns but disappear on unmount, toolset unload, or + DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node built-ins (require/setTimeout/fetch) — use the runtime's cordis services via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small single-purpose plugins, prefer plain notification events over waterfall diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index b5f8e27564..7d9da3f7a2 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -37,16 +37,16 @@ function resultText(result: { content: { type: string; text?: string }[] }): str } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { + it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' - + 'cordis event and logs every change with console.log. Reply "mounted" once done.', - }]) + text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' + + 'Cordis event and logs every change with console.log. Reply "running" once done.', + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The WORLD check: the turn's own running→idle transition must have driven @@ -54,32 +54,32 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(taggedCalls(log).length).toBeGreaterThan(0) const mid = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(mid)).toContain('dyn-') - agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) - expect(resultText(after)).toContain('(no dynamic plugins mounted)') + expect(resultText(after)).toContain('No temporary Plugins are running.') }, 120_000) it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'to register a tool named reverse_text with one required string parameter ' + '"text", returning the text reversed. Then CALL reverse_text with the ' + 'exact text "harness" and report its exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the tool exists in the registry, was invoked as a real tool call, and its @@ -115,19 +115,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ).toBe(true) }, 120_000) - it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { + it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' + 'a tool named shout_text with one required string parameter "text" whose execute returns ' + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' + 'and report the exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the service is really in the store, the tool really ran. @@ -144,16 +144,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, dynamic table naming the missing service. + // dependent tool unregistered, temporary section naming the missing service. expect(ctx.get('shouter')).toBeUndefined() expect(ctx.tools.get('shout_text')).toBeUndefined() const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(after)).toContain('waiting for: shouter') }, 120_000) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 014ce74f7c..2e12cd76c3 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -15,8 +15,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to add a plugin, cordis_unmount ' - + 'to clean one up. Follow the tool descriptions exactly and report results briefly.' + + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' + + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' export async function cordisHarness(): Promise { const ctx = new Context() diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index e467d32833..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek + plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_headless_subprocess plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -54,6 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 944fe7e180..896c73469b 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -19,6 +19,10 @@ - id: deepseek-v4-flash contextWindow: 128000 +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml index b853410ef0..f6eeeb05ec 100644 --- a/examples/headless-agent/goal.cordis.snapshot.yml +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -1,12 +1,18 @@ -# Replay counterpart to goal.cordis.yml; only the live model is replaced. +# Replay counterpart to goal.cordis.yml. It includes cordis.yml directly because +# a config patch cannot target an entry behind a nested include, then restates +# the goal overlay while replacing the live model with keyless replay. - id: base name: '@cordisjs/plugin-include' config: - path: ./goal.cordis.yml + path: ./cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/retry.cordis.snapshot.yml b/examples/headless-agent/retry.cordis.snapshot.yml new file mode 100644 index 0000000000..b8fc79d17f --- /dev/null +++ b/examples/headless-agent/retry.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Keyless provider-retry composition for the headless stream-json snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: retry-snapshot-backend + name: './tests/fixtures/retry-snapshot-backend.mjs' diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index fc7959e25d..432b64eb9f 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -17,6 +17,10 @@ file: !!js process.env.DSH_SNAPSHOT_FILE overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ef84d7309f..bee296333e 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness @@ -248,7 +251,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { expect(ctx.tasks.list()).toEqual([]) }, 15_000) - it('uses cordis_mount DTO ids directly for active and pending mounts, then confirms removal', async () => { + it('uses cordis_mount DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => { ctx = await typedCodeModeHarness() await ctx.plugin(ToolCordis) @@ -259,14 +262,14 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { const pending = await tools.cordis_mount({ code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }", }); - const before = await tools.cordis_inspect({ what: 'dynamic' }); - const unmounted = await tools.cordis_unmount({ id: active.id }); - const after = await tools.cordis_inspect({ what: 'dynamic' }); + const before = await tools.cordis_inspect({ what: 'temporary' }); + const stopped = await tools.cordis_unmount({ id: active.id }); + const after = await tools.cordis_inspect({ what: 'temporary' }); await tools.cordis_unmount({ id: pending.id }); return { active, pending, - unmounted, + stopped, beforeContainsId: before.includes(active.id), afterContainsId: after.includes(active.id), }; @@ -287,7 +290,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { provides: [], waitingFor: ['missing-code-mode-service'], }, - unmounted: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, + stopped: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, beforeContainsId: true, afterContainsId: false, }) @@ -311,12 +314,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' + 'and return only the joined string.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events: SessionEvent[] = [...agent.session.events] @@ -363,21 +366,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.followup([{ + handle.agent.followup({ content: [{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, handle.agent) const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') + && event.data.source.kind === 'workspace-instructions') expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() expect(workspaceContext).toBeDefined() diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index 4a827858bb..3b7e200582 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,12 +56,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' + 'Do not modify add.test.js.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The agent claims success… diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index a96b7f3611..48b73c3e6e 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,12 +46,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' + 'many files you read and the number mentioned in file1.txt.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 0f0b02c1d9..72aa7b199d 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -59,7 +59,7 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) - ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { const config = await next() return step === 2 ? { ...config, reasoningEffort: OFF } : config }) diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index bc9b71685e..d66713526e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -2,6 +2,10 @@ - id: cli-mock-llm name: '../cli-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index 254870eca9..cb0e072d2e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,7 +7,7 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/pre-step', (agent) => { + ctx.on('agent/step', (agent) => { if (ctx.goals.get(agent) !== undefined) return ctx.goals.create(agent, { objective: 'Prove the composed goal survives in the session log', diff --git a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs new file mode 100644 index 0000000000..28dc4f5742 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs @@ -0,0 +1,53 @@ +/** Deterministic provider adapter for the headless retry-policy snapshot. */ + +import { + LlmAdapter, + LlmError, + resolveRetryPolicy, +} from '@deepseek-ai/dsh-llm' + +class RetrySnapshotAdapter extends LlmAdapter { + requests = 0 + firstMessages + policy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'retry-snapshot-backend.retryPolicy') + + providerRetryPolicy() { + return this.policy + } + + async * stream(options) { + const messages = JSON.stringify(options.messages) + this.requests++ + if (this.requests === 1) { + this.firstMessages = messages + throw new LlmError('snapshot transient failure', 'RATE_LIMIT', { status: 429 }) + } + if (this.requests === 2 && messages !== this.firstMessages) { + throw new Error('retry snapshot changed the model-visible messages') + } + const text = 'RETRY_OK' + 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: 4, outputTokens: 2 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Cordis plugin name. */ +export const name = 'retry-snapshot-backend' +/** Required LLM registry service. */ +export const inject = ['llm'] + +/** + * Register the deterministic provider adapter. + * @param {import('cordis').Context} ctx - plugin context carrying the LLM service. + */ +export function apply(ctx) { + ctx.llm.registerAdapter(['deepseek'], new RetrySnapshotAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader + * composition against it, run one turn whose prompt carries a fixture + * credential, then persist everything the collector captured to + * `./otlp-captures.json` for the e2e's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +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('telemetry-otel driver requires a config path') + +const captures: unknown[] = [] +const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push(JSON.parse(Buffer.concat(chunks).toString())) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const address = server.address() +if (address === null || typeof address === 'string') throw new Error('collector has no port') +process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs` + +const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined)) +try { + // The fixture credential rides the model-visible user message; the exported + // copy must scrub it while the canonical log keeps the original bytes. + await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) +} finally { + await ctx.fiber.dispose() +} +await writeFile('./otlp-captures.json', JSON.stringify(captures)) +server.close() +server.closeAllConnections() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..34e23b828e --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,28 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +# The redact-rule entry models a deployment mounting its own scrub rule on the +# telemetry/record waterfall — the seam itself ships no rules. +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: telemetry-redact-rule + name: './telemetry-redact-rule.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_E2E_URL + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persona: 'Test the session-telemetry-otel plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..7a2aa7958a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -0,0 +1,29 @@ +import type { Context } from 'cordis' + +/** + * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture + * credential from body strings, exactly as a real deployment would mount its + * own rules on the `telemetry/record` waterfall. + */ + +const SECRET = /sk-e2efixture[0-9]+/g +const PLACEHOLDER = '[E2E-REDACTED]' + +function scrub(value: unknown): unknown { + if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER) + if (Array.isArray(value)) return value.map(scrub) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])) + } + return value +} + +export const name = 'telemetry-redact-rule' + +/** Mount the fixture scrub rule onto the redact waterfall. */ +export function apply(ctx: Context): void { + ctx.on('telemetry/record', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 91ba8a1254..a105652e9c 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -2,6 +2,10 @@ - id: time-context-mock-llm name: './time-context-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index 4f61ec3fa3..9f50a77937 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..f0cde5c274 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index f474cb5460..6bad95d926 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -24,6 +24,8 @@ const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl') const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url)) const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) +const retryScenarioDir = join(snapshotsDir, 'provider-retry') +const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) @@ -125,6 +127,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('retries a transient provider failure through the one-shot app', async () => { + const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') + const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'provider retry headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-provider-retry-', + binScript, + configPath: retryConfigPath, + binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const retries = records.filter(record => record.type === 'llm/retry') + expect(retries).toHaveLength(1) + expect(retries[0]?.data).toMatchObject({ + provider: 'deepseek', + mode: 'normal', + policyKey: '["normal",1,["RATE_LIMIT"],1,1,0]', + retry: 1, + maxRetries: 1, + delayMs: 1, + failure: { message: 'snapshot transient failure', code: 'RATE_LIMIT', status: 429 }, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('logs the model default and a dynamic next-step reasoning effort', async () => { const result = await runLoaderSmoke({ label: 'reasoning effort headless stream-json snapshot', @@ -272,14 +314,16 @@ describe('headless stream-json snapshots', () => { const goalChanges = records.filter((record) => { if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - return meta?.kind === 'goal/change' + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + return source?.kind === 'goal' && change?.kind === 'goal/change' }) expect(goalChanges).toHaveLength(1) const data = goalChanges[0]?.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - const goal = meta?.goal as JsonObject | undefined - expect(meta?.operation).toBe('create') + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + const goal = change?.goal as JsonObject | undefined + expect(change?.operation).toBe('create') expect(goal).toMatchObject({ objective: 'Finish the headless goal-tool snapshot proof', phase: 'active', diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index a4ded767da..2c9a96e099 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup({ content: [{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }], source: { kind: 'user' } }) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup({ content: [{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }], source: { kind: 'user' } }) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json index 41072a211a..3a6418d41c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "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." } + { "op": "prompt", "text": "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } ] } 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 6cae860e36..9c9dc2c2ce 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"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":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","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":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} 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 c00a4119c7..618a6f1af7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"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":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","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":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 9d2b188a45..996d9a81aa 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,9 +1,9 @@ {"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":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** 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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** 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). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"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. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","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":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. 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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":true,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** 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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** 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\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** 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. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** 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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** 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). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `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). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now 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 } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins 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 by cordis_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 when unmounted) — 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":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"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."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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":7,"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() {} }\"}"}}}} @@ -11,19 +11,19 @@ {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}} -{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} +{"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785037378917,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785037378920,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785037379536,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785037379538,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 30dea5ebe8..339595168d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -1,5 +1,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run 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":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} @@ -10,19 +10,19 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -52,7 +52,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 16518b2d03..55b4078534 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -21,7 +21,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/input.json b/examples/headless-agent/tests/snapshots/provider-retry/input.json new file mode 100644 index 0000000000..2dc62032a6 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "retry the transient provider failure" + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl new file mode 100644 index 0000000000..d595b8ab6a --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -0,0 +1,19 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":6,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"retry"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"RETRY_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":2,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":2,"result":"RETRY_OK","reason":{"kind":"completed"},"usage":{"inputTokens":4,"outputTokens":2}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 99eaf6e4ee..9e2aa427d3 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"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","additionalProperties":true,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse 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.\n\nUse 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.\n\nUse 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.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack 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.\n\nUse 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.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","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]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. 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. 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; read its output with `task_output` and stop it with `task_kill`.","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 (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"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":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"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. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","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."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"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","additionalProperties":false,"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?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","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).","additionalProperties":true,"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","additionalProperties":true,"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."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"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\": [...]}).","additionalProperties":true}},"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."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index c3053572d9..9a9ccb72cc 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,10 +28,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: + agent.followup({ content: [{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' - + 'Send both in one todo_write call, then reply with the single word DONE.' }]) + + 'Send both in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..b1364679f9 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -17,6 +17,10 @@ thinking: enabled reasoningEffort: max +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/package.json b/examples/package.json index b0938c92c1..0e3bfb84b0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..c6fc223113 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -69,6 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index e96ef680b2..7c8b03db05 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -21,6 +21,10 @@ reasoningEffort: max # Local executor for the app bundle's bash tool. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index d3e35b6ce5..517fa6adab 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -80,10 +80,19 @@ class ScriptedTuiAdapter extends LlmAdapter { throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) - const lastText = (lastMessage?.content ?? []) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') + // The loop appends plugin-sourced context (the plan-mode notice, the + // tool-skill catalog) AFTER the admitted prompt, so the scripted trigger + // may sit one or more user messages back: scan the whole trailing run of + // user-role messages since the last assistant message. + const trailingUserTexts: string[] = [] + for (let index = options.messages.length - 1; index >= 0; index--) { + const message = options.messages[index] + if (message?.role !== 'user') break + for (const block of message.content) { + if (block.type === 'text') trailingUserTexts.push(block.text) + } + } + const lastText = trailingUserTexts.join('\n') if (lastText.includes(DEFAULT_MODE_PROBE)) { if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) { throw new Error('the scripted TUI request retained plan guidance after /plan off') diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 307c5a1dfe..a32bdf987b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -4,6 +4,10 @@ - id: scripted-llm name: './tui-scripted-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 9ffee44378..0d8f454204 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,63 +1,54 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 +cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the bash tool to" style 1-20 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " The user wants me to run a simple bash command and then reply with \"DONE\". " - style 1-74 fg=bright-black italic -11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ echo TERMINAL_OK " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -14| "▌ Echo TERMINAL_OK to verify terminal access " - style 0-0 fg=green - style 2-43 fg=bright-black -15| "▌ TERMINAL_OK " - style 0-0 fg=green -16| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -17| "▌ " - style 0-0 fg=green -18| -19| " Reasoning " - style 1-9 fg=bright-black italic -20| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 1-91 fg=bright-black italic -21| -22| " Assistant " - style 1-9 fg=bright-magenta bold -23| " DONE " -24| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -25| " " - style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% 3% contex" - style 0-88 dim - style 91-99 dim -28-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 fg=bright-black italic +9| "The user wants me to run a simple bash command and then reply with \"DONE\". " + style 0-73 fg=bright-black italic +10| +11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" + style 0-57 fg=green +12| "$ echo TERMINAL_OK " + style 0-17 fg=cyan +13| "TERMINAL_OK " +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 fg=bright-black italic +19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 0-90 fg=bright-black italic +20| "DONE " +21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +22| +23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" + style 0-46 fg=bright-blue bold + style 49-65 fg=bright-black + style 68-88 fg=bright-black + style 91-99 fg=bright-black +24| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +25-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index aad4b2cd50..248ea2f4e7 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -1,65 +1,57 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=26 bufferRow=26 +cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " - style 0-0 fg=bright-blue - style 79-99 fg=cyan -7| "▌ '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " - style 0-0 fg=bright-blue - style 2-74 fg=cyan -8| "▌ number of lines in its output. Reply with just that number and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 1-99 fg=bright-black italic -13| " command, then returns only the number of lines in its output. " - style 1-61 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Count lines in seq/awk output " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-32 bold -17| "▌ 200 " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The result is 200 lines. The user wants me to reply with just that number and stop. " - style 1-83 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " 200 " -25| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -26| " " - style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" - style 0-93 dim - style 96-99 dim -29-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " + style 77-99 fg=cyan +6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " + style 0-72 fg=cyan +7| "number of lines in its output. Reply with just that number and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " + style 0-99 fg=bright-black italic +12| "command, then returns only the number of lines in its output. " + style 0-60 fg=bright-black italic +13| +14| "● Tool / run_code" + style 0-16 fg=green +15| "Count lines in seq/awk output " +16| "200 " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The result is 200 lines. The user wants me to reply with just that number and stop. " + style 0-82 fg=bright-black italic +22| "200 " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24| +25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-93 fg=bright-black + style 96-99 fg=bright-black +26| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +27-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 8dd0c37da1..45879f889f 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,136 +1,140 @@ -terminal 100x36 buffer=normal length=64 base=28 viewport=28 +terminal 100x36 buffer=normal length=62 base=26 viewport=26 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=61 +cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " - style 0-0 fg=bright-blue - style 65-77 fg=cyan - style 92-99 fg=cyan -7| "▌ CODE_TWO. Inside that same program, console.log exactly captured output, then return the two " - style 0-0 fg=bright-blue - style 2-9 fg=cyan - style 58-72 fg=cyan -8| "▌ outputs joined with a plus sign. Reply with that joined string only and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that: " - style 1-36 fg=bright-black italic - style 37-44 fg=cyan - style 45-58 fg=bright-black italic -13| " 1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 1-3 fg=bright-blue - style 4-9 fg=bright-black italic - style 10-13 fg=cyan - style 14-38 fg=bright-black italic - style 39-51 fg=cyan - style 52-63 fg=bright-black italic - style 64-76 fg=cyan -14| " 2. console.log exactly captured output " - style 1-3 fg=bright-blue - style 4-14 fg=cyan - style 15-23 fg=bright-black italic - style 24-38 fg=cyan -15| " 3. Returns the two outputs joined with a plus sign " - style 1-3 fg=bright-blue - style 4-50 fg=bright-black italic -16| " " -17| " Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 1-38 fg=bright-black italic - style 39-42 fg=cyan - style 43-99 fg=bright-black italic -18| " extract the stdout text from each call. " - style 1-39 fg=bright-black italic -19| " " -20| " Looking at the bash output type: " - style 1-32 fg=bright-black italic +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 63-75 fg=cyan + style 90-99 fg=cyan +6| "CODE_TWO. Inside that same program, console.log exactly captured output, then return the two outputs" + style 0-7 fg=cyan + style 56-70 fg=cyan +7| "joined with a plus sign. Reply with that joined string only and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that: " + style 0-35 fg=bright-black italic + style 36-43 fg=cyan + style 44-57 fg=bright-black italic +12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " + style 0-2 fg=bright-blue + style 3-8 fg=bright-black italic + style 9-12 fg=cyan + style 13-37 fg=bright-black italic + style 38-50 fg=cyan + style 51-62 fg=bright-black italic + style 63-75 fg=cyan +13| "2. console.log exactly captured output " + style 0-2 fg=bright-blue + style 3-13 fg=cyan + style 14-22 fg=bright-black italic + style 23-37 fg=cyan +14| "3. Returns the two outputs joined with a plus sign " + style 0-2 fg=bright-blue + style 3-49 fg=bright-black italic +15| " " +16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " + style 0-37 fg=bright-black italic + style 38-41 fg=cyan + style 42-99 fg=bright-black italic +17| "extract the stdout text from each call. " + style 0-38 fg=bright-black italic +18| " " +19| "Looking at the bash output type: " + style 0-31 fg=bright-black italic +20| " " 21| " " -22| " ``` " - style 1-3 dim -23| " { " -24| " kind: \"foreground\"; " -25| " exitCode: number | null; " -26| " signal: string | null; " -27| " timedOut: boolean; " -28| " aborted: boolean; " -29| " timeoutMs: number; " -30| " stdout: { " -31| " text: string; " -32| " truncated: boolean; " -33| " spillPath?: string; " -34| " }; " -35| " stderr: { ... }; " -36| " sandbox?: { ... }; " -37| " } " -38| " ``` " - style 1-3 dim -39| " " -40| " So I need to access .stdout.text from each result. " - style 1-20 fg=bright-black italic - style 21-32 fg=cyan - style 33-50 fg=bright-black italic -41| " " -42| " Let me write the program. " - style 1-25 fg=bright-black italic -43| -44| "▌ " - style 0-0 fg=green -45| "▌ ✓ Call bash twice, log, join outputs " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-37 bold -46| "▌ captured output " - style 0-0 fg=green -47| "▌ CODE_ONE+CODE_TWO " - style 0-0 fg=green -48| "▌ " - style 0-0 fg=green -49| -50| " Reasoning " - style 1-9 fg=bright-black italic -51| " The program ran successfully. The output shows: " - style 1-47 fg=bright-black italic -52| " - captured output (from console.log) " - style 1-2 fg=bright-blue - style 3-17 fg=cyan - style 18-36 fg=bright-black italic -53| " - CODE_ONE+CODE_TWO (the returned joined string) " - style 1-2 fg=bright-blue - style 3-19 fg=cyan - style 20-48 fg=bright-black italic +22| " { " + style 2-2 fg=cyan +23| " kind: \"foreground\"; " + style 2-22 fg=cyan +24| " exitCode: number | null; " + style 2-27 fg=cyan +25| " signal: string | null; " + style 2-25 fg=cyan +26| " timedOut: boolean; " + style 2-21 fg=cyan +27| " aborted: boolean; " + style 2-20 fg=cyan +28| " timeoutMs: number; " + style 2-21 fg=cyan +29| " stdout: { " + style 2-12 fg=cyan +30| " text: string; " + style 2-18 fg=cyan +31| " truncated: boolean; " + style 2-24 fg=cyan +32| " spillPath?: string; " + style 2-24 fg=cyan +33| " }; " + style 2-5 fg=cyan +34| " stderr: { ... }; " + style 2-19 fg=cyan +35| " sandbox?: { ... }; " + style 2-21 fg=cyan +36| " } " + style 2-2 fg=cyan +37| " " +38| " " +39| "So I need to access .stdout.text from each result. " + style 0-19 fg=bright-black italic + style 20-31 fg=cyan + style 32-49 fg=bright-black italic +40| " " +41| "Let me write the program. " + style 0-24 fg=bright-black italic +42| +43| "● Tool / run_code" + style 0-16 fg=green +44| "Call bash twice, log, join outputs " +45| "captured output " +46| "CODE_ONE+CODE_TWO " +47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +48| +49| "Assistant " + style 0-8 fg=bright-magenta bold underline +50| "Reasoning " + style 0-8 fg=bright-black italic +51| "The program ran successfully. The output shows: " + style 0-46 fg=bright-black italic +52| "- captured output (from console.log) " + style 0-1 fg=bright-blue + style 2-16 fg=cyan + style 17-35 fg=bright-black italic +53| "- CODE_ONE+CODE_TWO (the returned joined string) " + style 0-1 fg=bright-blue + style 2-18 fg=cyan + style 19-47 fg=bright-black italic 54| " " -55| " The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 1-99 fg=bright-black italic -56| " CODE_ONE+CODE_TWO. " - style 1-17 fg=cyan - style 18-18 fg=bright-black italic -57| -58| " Assistant " - style 1-9 fg=bright-magenta bold -59| " CODE_ONE+CODE_TWO " -60| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -61| " " - style 1-1 inverse -62| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -63| "deepseek-v4-flash /workspace/project ↑182 ↓446 cache 98% 4% context tools:c" - style 0-78 dim - style 81-99 dim +55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " + style 0-99 fg=bright-black italic +56| "CODE_ONE+CODE_TWO. " + style 0-16 fg=cyan + style 17-17 fg=bright-black italic +57| "CODE_ONE+CODE_TWO " +58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +59| +60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-78 fg=bright-black + style 81-90 fg=bright-black +61| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse 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 9a80b08e9e..b9ce1d12da 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,6 +1,6 @@ {"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":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop 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":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -10,17 +10,17 @@ {"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"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":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}}} {"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}} -{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} {"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} @@ -51,7 +51,7 @@ {"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} {"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a3739deefa..b4add028d8 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,106 +1,95 @@ -terminal 100x36 buffer=normal length=48 base=12 viewport=12 +terminal 100x36 buffer=normal length=59 base=23 viewport=23 lifecycle started=1 stopped=0 progress=inactive title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=45 +cursor hidden column=7 viewportRow=35 bufferRow=58 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Run this advanced flow exactly" style 1-30 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " - style 0-0 fg=bright-blue -7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " - style 0-0 fg=bright-blue -8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " - style 0-0 fg=bright-blue -9| "▌ reply with exactly ADVANCED_ACP_OK. " - style 0-0 fg=bright-blue -10| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use " +6| "run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a " +7| "direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply " +8| "with exactly ADVANCED_ACP_OK. " +9| +10| "Assistant " + style 0-8 fg=bright-magenta bold underline 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Mount plugin into live cordis runtime " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-40 bold -14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| -17| "▌ " - style 0-0 fg=green -18| "▌ ✓ Verify the dynamically mounted marker service " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-48 bold -19| "▌ ## dynamic " - style 0-0 fg=green -20| "▌ - dyn-1: snapshot-marker [active] " - style 0-0 fg=green -21| "▌ " - style 0-0 fg=green -22| -23| "▌ " - style 0-0 fg=green -24| "▌ ✓ subagent " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-11 bold -25| "▌ DIRECT_CHILD_OK " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| -28| "▌ " - style 0-0 fg=green -29| "▌ ✓ workflow: advanced-acp-snapshot " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-34 bold -30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " - style 0-0 fg=green -31| "▌ Return value: " - style 0-0 fg=green -32| "▌ { " - style 0-0 fg=green -33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " - style 0-0 fg=green -34| "▌ } " - style 0-0 fg=green -35| "▌ " - style 0-0 fg=green +12| "● Tool / cordis_mount" + style 0-20 fg=green +13| "Mount temporary Cordis Plugin " +14| "Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " +15| "restarts). " +16| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +17| +18| "Assistant " + style 0-8 fg=bright-magenta bold underline +19| +20| "● Tool / run_code" + style 0-16 fg=green +21| "Verify the temporary marker Plugin " +22| " " +23| "Temporary Plugins " + style 0-16 fg=bright-blue bold +24| " " +25| "- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: " + style 0-1 fg=bright-blue +26| " until unmounted or DSH restarts " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Assistant " + style 0-8 fg=bright-magenta bold underline +30| +31| "● Tool / subagent" + style 0-16 fg=green +32| "DIRECT_CHILD_OK " +33| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +34| +35| "Assistant " + style 0-8 fg=bright-magenta bold underline 36| -37| "▌ " - style 0-0 fg=green -38| "▌ ✓ Unmount dyn-1 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " - style 0-0 fg=green -40| "▌ " - style 0-0 fg=green -41| -42| " Assistant " - style 1-9 fg=bright-magenta bold -43| " ADVANCED_ACP_OK " -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" - style 0-90 dim - style 93-99 dim +37| "● Tool / workflow" + style 0-16 fg=green +38| "workflow: advanced-acp-snapshot " +39| "workflow \"advanced-acp-snapshot\" completed (1 agent). " +40| "Return value: " +41| "{ " +42| " \"reply\": \"WORKFLOW_CHILD_OK\" " +43| "} " +44| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +45| +46| "Assistant " + style 0-8 fg=bright-magenta bold underline +47| +48| "● Tool / cordis_unmount" + style 0-22 fg=green +49| "Unmount temporary Cordis Plugin dyn-1 " +50| "Temporary Plugin dyn-1 was unmounted and removed. " +51| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +52| +53| "Assistant " + style 0-8 fg=bright-magenta bold underline +54| "ADVANCED_ACP_OK " +55| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +56| +57| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-90 fg=bright-black + style 93-99 fg=bright-black +58| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 48adbbb11a..c704cb2399 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,96 +1,80 @@ -terminal 100x36 buffer=normal length=45 base=9 viewport=9 +terminal 100x36 buffer=normal length=43 base=7 viewport=7 lifecycle started=1 stopped=0 progress=inactive title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=42 +cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the workflow tool exactly" style 1-29 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " - style 0-0 fg=bright-blue -7| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " - style 0-0 fg=bright-blue -8| "▌ phase('Run') " - style 0-0 fg=bright-blue -9| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " - style 0-0 fg=bright-blue -10| "▌ return { reply } " - style 0-0 fg=bright-blue -11| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " - style 0-0 fg=bright-blue -12| "▌ other tool. " - style 0-0 fg=bright-blue -13| "▌ " - style 0-0 fg=bright-blue -14| -15| " Reasoning " - style 1-9 fg=bright-black italic -16| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 1-99 fg=bright-black italic -17| " follow the instructions: " - style 1-24 fg=bright-black italic -18| " " -19| " 1. args omitted (so I don't include it) " - style 1-3 fg=bright-blue - style 4-39 fg=bright-black italic -20| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 1-3 fg=bright-blue - style 4-82 fg=bright-black italic -21| " 3. script = as given verbatim " - style 1-3 fg=bright-blue - style 4-29 fg=bright-black italic -22| " 4. After it returns, reply with \"WORKFLOW_DONE\" " - style 1-3 fg=bright-blue - style 4-47 fg=bright-black italic -23| " " -24| " Let me do exactly that. " - style 1-23 fg=bright-black italic -25| -26| "▌ " - style 0-0 fg=green -27| "▌ ✓ workflow: snapshot-flow " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -28| "▌ workflow \"snapshot-flow\" completed (1 agent). " - style 0-0 fg=green -29| "▌ Return value: " - style 0-0 fg=green -30| "▌ { " - style 0-0 fg=green -31| "▌ \"reply\": \"WF_CHILD_OK\" " - style 0-0 fg=green -32| "▌ } " - style 0-0 fg=green -33| "▌ " - style 0-0 fg=green -34| -35| " Reasoning " - style 1-9 fg=bright-black italic -36| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 1-99 fg=bright-black italic -37| " \"WORKFLOW_DONE\" and stop. " - style 1-25 fg=bright-black italic -38| -39| " Assistant " - style 1-9 fg=bright-magenta bold -40| " WORKFLOW_DONE " -41| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -42| " " - style 1-1 inverse -43| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% 3% context " - style 0-86 dim - style 89-99 dim +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " +6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " +7| "phase('Run') " +8| "const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " +9| "return { reply } " +10| "After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other " +11| "tool. " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| "Reasoning " + style 0-8 fg=bright-black italic +15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 0-99 fg=bright-black italic +16| "follow the instructions: " + style 0-23 fg=bright-black italic +17| " " +18| "1. args omitted (so I don't include it) " + style 0-2 fg=bright-blue + style 3-38 fg=bright-black italic +19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 0-2 fg=bright-blue + style 3-81 fg=bright-black italic +20| "3. script = as given verbatim " + style 0-2 fg=bright-blue + style 3-28 fg=bright-black italic +21| "4. After it returns, reply with \"WORKFLOW_DONE\" " + style 0-2 fg=bright-blue + style 3-46 fg=bright-black italic +22| " " +23| "Let me do exactly that. " + style 0-22 fg=bright-black italic +24| +25| "● Tool / workflow" + style 0-16 fg=green +26| "workflow: snapshot-flow " +27| "workflow \"snapshot-flow\" completed (1 agent). " +28| "Return value: " +29| "{ " +30| " \"reply\": \"WF_CHILD_OK\" " +31| "} " +32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +33| +34| "Assistant " + style 0-8 fg=bright-magenta bold underline +35| "Reasoning " + style 0-8 fg=bright-black italic +36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 0-99 fg=bright-black italic +37| "\"WORKFLOW_DONE\" and stop. " + style 0-24 fg=bright-black italic +38| "WORKFLOW_DONE " +39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +40| +41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" + style 0-44 fg=bright-blue bold + style 47-63 fg=bright-black + style 66-86 fg=bright-black + style 89-98 fg=bright-black +42| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 099bf63b81..e4fa63468a 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,70 +1,62 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=33 +cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Reply with exactly the word:" style 1-28 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 1-72 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " ONE " +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Reply with exactly the word: ONE. No tools. " +6| +7| "Entering plan mode (applies from the next step). Use /plan off to leave. " + style 0-71 fg=bright-black +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 0-71 fg=bright-black italic +12| "ONE " +13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +14| +15| "Leaving plan mode (applies from the next step). " + style 0-46 fg=bright-black 16| -17| " Leaving plan mode (applies from the next step). " - style 1-47 fg=bright-black -18| -19| " Context · plan-mode " - style 1-19 dim -20| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Reply with exactly the word: TWO. No tools. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 1-68 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " TWO " -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| " " - style 1-1 inverse -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co" - style 0-92 dim - style 95-99 dim +17| "You " + style 0-2 fg=bright-blue bold underline +18| "Reply with exactly the word: TWO. No tools. " +19| +20| "Context · plan-mode " + style 0-18 dim +21| "The user switched this session back to the default mode. " + style 0-55 fg=bright-black +22| +23| "Assistant " + style 0-8 fg=bright-magenta bold underline +24| "Reasoning " + style 0-8 fg=bright-black italic +25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 0-67 fg=bright-black italic +26| "TWO " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" + style 0-51 fg=bright-blue bold + style 54-70 fg=bright-black + style 73-92 fg=bright-black + style 95-99 fg=bright-black +30| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 70fa5b173a..3d99c45758 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,81 +1,52 @@ -terminal 100x36 buffer=normal length=37 base=1 viewport=1 +terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=34 +cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the read tool twice" style 1-23 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ Read a.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -11| "▌ /workspace/project/a.txt " - style 0-0 fg=green -12| "▌ file " - style 0-0 fg=green -13| "▌ " - style 0-0 fg=green -14| "▌ 1: alpha " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -17| "▌ " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Read b.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -22| "▌ /workspace/project/b.txt " - style 0-0 fg=green -23| "▌ file " - style 0-0 fg=green -24| "▌ " - style 0-0 fg=green -25| "▌ 1: beta " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -28| "▌ " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| " Assistant " - style 1-9 fg=bright-magenta bold -32| " DONE " -33| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -34| " " - style 1-1 inverse -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% 3% context t" - style 0-84 dim - style 87-99 dim +9| "● Tool / read" + style 0-12 fg=green +10| "Read a.txt " +11| "1: alpha " +12| " " +13| "(End of file - total 1 lines) " +14| +15| "● Tool / read" + style 0-12 fg=green +16| "Read b.txt " +17| "1: beta " +18| " " +19| "(End of file - total 1 lines) " +20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +21| +22| "Assistant " + style 0-8 fg=bright-magenta bold underline +23| "DONE " +24| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +25| +26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" + style 0-47 fg=bright-blue bold + style 50-66 fg=bright-black + style 69-84 fg=bright-black + style 87-96 fg=bright-black +27| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +28-35| diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index ec38d43c99..9dafcf576d 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,57 +1,48 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=31 bufferRow=31 +cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the todo_write tool to" style 1-26 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " - style 0-0 fg=bright-blue -7| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " - style 0-0 fg=bright-blue -8| "▌ reply with the single word DONE and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 1-99 fg=bright-black italic -13| " specified statuses, then reply with \"DONE\". " - style 1-43 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Update todo list " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -17| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 1-95 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " DONE " -25| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " +6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " +7| "reply with the single word DONE and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 0-99 fg=bright-black italic +12| "specified statuses, then reply with \"DONE\". " + style 0-42 fg=bright-black italic +13| +14| "● Tool / todo_write" + style 0-18 fg=green +15| "Update todo list " +16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 0-94 fg=bright-black italic +22| "DONE " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24-25| 26| "Plan" style 0-3 fg=bright-blue bold 27| " ● read the code" @@ -60,13 +51,13 @@ buffer style 2-2 dim 29| " ○ run the tests" style 2-2 dim -30| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -31| " " - style 1-1 inverse -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% 3% context tools:" - style 0-79 dim - style 82-99 dim -34-35| +30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-79 fg=bright-black + style 82-91 fg=bright-black +31| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +32-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 1cbe6c478e..cdfd7d704d 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -71,17 +71,37 @@ async function seedResumeSession(cwd: string): Promise { ].join('\n')) } -/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ -async function readLoggedSystemPrompt(cwd: string): Promise { +/** Model-visible startup context from the first request in the workspace's persisted session log. */ +interface LoggedRequestContext { + /** The system prompt string the launcher sends. */ + system: string + /** The durable skill-catalog message serialized to text. */ + skillCatalog: string +} + +async function readLoggedRequestContext(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') const entries = await readdir(sessionsDir, { recursive: true }) // A single keyless run writes one session log; the source section is global, so any log carries it. const logRelPath = entries.find(name => name.endsWith('.jsonl')) if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + let skillCatalog = '' for (const line of lines) { - const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } } - if (event.type === 'request/header') return event.data.header?.system ?? '' + const event = JSON.parse(line) as SessionEvent + if ( + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill' + ) { + skillCatalog = JSON.stringify(event.data.content) + } + if (event.type === 'request/header') { + return { + system: event.data.header.system ?? '', + skillCatalog, + } + } } throw new Error(`session log ${logRelPath} has no request/header event`) } @@ -176,6 +196,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') + expect(output).toContain('System prompt') + expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(output).toContain('Registered tools') + expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -208,6 +232,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { { waitFor: 'Scripted skill body received.', send: '/exit\r' }, ], }) + expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -342,11 +367,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('tells the model where its own source lives, in the system prompt it sends', async () => { + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. + // The source-path line is a system-prompt section; the bundled skills reach the + // model through a durable user message, so each assertion targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let loggedSystem = '' + let context: LoggedRequestContext = { system: '', skillCatalog: '' } await smoke({ label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', @@ -358,8 +385,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], - inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, + inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, }) - expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") + expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') + expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..45e0e5538d 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -2,12 +2,13 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -25,7 +26,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -220,11 +221,13 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped // config that omits it. The other scenarios prove the default todo-free composition. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) @@ -262,6 +265,7 @@ interface ScenarioResult { } async function runScenario(scenario: Scenario): Promise { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime()) const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) @@ -294,7 +298,7 @@ async function runScenario(scenario: Scenario): Promise { const agent: Agent = handle.agent controller = createTuiChat(ctx, { sessionId: 'main-session', - color: true, + theme: { color: true }, showReasoning: true, title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, @@ -404,6 +408,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + clock.mockRestore() } } diff --git a/examples/web-cordis/.gitignore b/examples/web-cordis/.gitignore new file mode 100644 index 0000000000..4da346bc81 --- /dev/null +++ b/examples/web-cordis/.gitignore @@ -0,0 +1,2 @@ +.sessions/ +.storages/ diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml new file mode 100644 index 0000000000..80b598c696 --- /dev/null +++ b/examples/web-cordis/cordis.yml @@ -0,0 +1,18 @@ +# Opt-in Web composition for inspecting the self-referential Cordis tools. +# Temporary Plugin code can reach every injected live capability; treat this +# deployment like shell access, not as a security boundary. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../apps/cli/cordis.yml + patches: + # AppCLIEntry normally injects this assembly-owned path before `dsh web` + # boots; the standalone Cordis launcher needs the equivalent patch here. + - id: webserver + config: + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + - insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/knip.json b/knip.json index 7ab7d47e5f..f4c0e5f24c 100644 --- a/knip.json +++ b/knip.json @@ -6,7 +6,8 @@ "ignoreBinaries": [ "bwrap", "python3", - "sandbox-exec" + "sandbox-exec", + "taskkill" ], "ignoreWorkspaces": [ "vendor/*", @@ -33,6 +34,8 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "headless-agent/tests/fixtures/telemetry-otel-driver.ts", + "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -181,6 +184,16 @@ "tests/**/*.ts" ] }, + "packages/telemetry/session-telemetry-otel": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/util/brand": { "project": [ "src/**/*.ts" @@ -263,8 +276,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -317,8 +336,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ @@ -489,15 +514,6 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-subprocess": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..ab7986ee2c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,11 +1,11 @@ # Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full # repository-wide gate matrix. -# Install: `pnpm exec lefthook install` (runs automatically via postinstall). +# Install: `node scripts/install-lefthook.mjs` (runs automatically via postinstall). pre-commit: jobs: - name: lint (staged) - glob: '*.{ts,mts,cts,mjs}' + glob: '*.{ts,tsx,mts,cts,mjs}' exclude: - 'vendor/*/src/**' run: node_modules/.bin/eslint --fix {staged_files} diff --git a/package.json b/package.json index d74a780925..3f7147ec97 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", + "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", @@ -106,6 +106,7 @@ }, "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9b2c44af22..5dd168f03e 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -1,6 +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 -README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 -README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 +# pnpm run verify-translation-pairing --write packages/README.md +README.md: d16e395a42e491461c0862227205931894c27e39 +README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f diff --git a/packages/README.md b/packages/README.md index d7427c3f98..d16e395a42 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,6 +13,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | @@ -36,6 +37,7 @@ Packages live at `packages///`; groups are containers, while names r | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6894aa7333..3fb4181ce7 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,6 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | @@ -36,6 +37,7 @@ | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 2a88af1184..8d4693d9d5 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -19,7 +19,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'max_tokens' case 'aborted': case 'disposed': - case 'rejected': case 'interrupted': return 'cancelled' case 'error': diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 3ca4767c67..a6228b9615 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -77,6 +77,12 @@ interface SessionRecord { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined + /** + * A failed turn's terminal reason, held until quiescence: a retry action + * closes the failed turn and opens a successor that adopts the prompt, so + * rejecting at `turn/end` would race the recovery. + */ + pendingError: Extract | undefined } | undefined } @@ -125,15 +131,11 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.resolve(reason) } - const settleFromTurnEnd = ( + const rejectFromError = ( inflight: NonNullable, - reason: TurnEndReason, + reason: Extract, ): void => { - if (reason.kind === 'error') { - inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) - return - } - inflight.resolve(turnEndToStopReason(reason)) + inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) } // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, @@ -162,10 +164,22 @@ export function apply(ctx: Context, config: AcpConfig): void { if (inflight.turn === undefined && event.data.trigger.kind === 'message' && event.data.trigger.source.kind === 'user') { inflight.turn = event.data.turn + } else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') { + // A recovery policy opened a retry turn on the failed history: the + // prompt rides it instead of rejecting on the failed turn's end. + inflight.turn = event.data.turn + inflight.pendingError = undefined } } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - record.inflight = undefined - settleFromTurnEnd(inflight, event.data.reason) + if (event.data.reason.kind === 'error') { + // Hold the rejection: request recovery may adopt the prompt with a + // successor turn; quiescence without one delivers this error. + inflight.turn = undefined + inflight.pendingError = event.data.reason + } else { + record.inflight = undefined + inflight.resolve(turnEndToStopReason(event.data.reason)) + } } } }) @@ -243,23 +257,47 @@ export function apply(ctx: Context, config: AcpConfig): void { const text = acpPromptToText(params.prompt) if (text.trim().length === 0) throw invalidParams('empty prompt') + // Not driving a retired agent is this bridge's contract: an + // agent-loop-only reload disposes the loop's agents while the bridge + // record survives, so validate the record against the live registry + // before sending — a disposed machine would accept the item silently. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } const stopReason = await new Promise((resolve, reject) => { // Arm the slot before followup() so a listener-driven synchronous // turn cannot slip past correlation; a synchronous followup() - // failure (an agent disposed outside the bridge, e.g. an - // agent-loop-only reload) must free the slot again or the session + // failure (invalid input) must free the slot again or the session // would reject every later prompt as already in flight. - record.inflight = { resolve, reject, turn: undefined } + const inflight: NonNullable = { + resolve, reject, turn: undefined, pendingError: undefined, + } + record.inflight = inflight try { - record.agent.followup([{ type: 'text', text }]) + record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + // The machine's send() contains listener failures and accepts + // any typed input; this guards a future synchronous throw so the + // slot cannot wedge. + /* v8 ignore start -- future-proofing guard, see above */ } catch (error: unknown) { record.inflight = undefined - // followup() throws only Errors (disposed agent / invalid input); - // the String arm is a defensive fallback for a non-Error throw. - /* v8 ignore next */ const detail = error instanceof Error ? error.message : String(error) throw internalError(`prompt was not queued: ${detail}`) } + /* v8 ignore stop */ + // Admission is pre-turn and retries outlive their failed turn, so a + // turnless slot settles only at quiescence: a held failure rejects + // (no retry adopted the prompt); no turn at all means admission + // discarded the prompt — report cancelled. + void record.agent.whenIdle().then(() => { + if (record.inflight !== inflight || inflight.turn !== undefined) return + record.inflight = undefined + if (inflight.pendingError !== undefined) { + rejectFromError(inflight, inflight.pendingError) + return + } + inflight.resolve('cancelled') + }) }) return { stopReason } }, diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 2fdf544500..7f5441e4df 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -9,7 +9,6 @@ describe('ACP automation codec', () => { [{ kind: 'max-tokens' }, 'max_tokens'], [{ kind: 'aborted' }, 'cancelled'], [{ kind: 'disposed' }, 'cancelled'], - [{ kind: 'rejected', reason: 'blocked' }, 'cancelled'], [{ kind: 'interrupted' }, 'cancelled'], [{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'], ] diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 1303cde0b2..ee57d51baf 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -21,7 +21,7 @@ describe('ACP connection ownership', () => { await harness.acpFiber.dispose() await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) @@ -44,7 +44,7 @@ describe('ACP connection ownership', () => { await harness.closeClientTransport() await harness.acpFiber.dispose() - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() }) @@ -58,10 +58,10 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await harness.abortClientTransport() - await vi.waitFor(() => { expect(agent.status).toBe('disposed') }) await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true) }) + expect(agent.status).toBe('idle') }) it('disconnect and plugin disposal share one quiescence boundary', async () => { @@ -73,7 +73,7 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()]) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 1a16647d01..e4929e59fb 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => { sessionId: SessionId('foreign'), agentOptions: { provider: 'mock', model: 'mock' }, }) - agent.followup([{ type: 'text', text: 'go' }]) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) await agent.whenIdle() expect(harness.updates).toHaveLength(0) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index cf081d1f2a..19c1bbe0dc 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -48,7 +48,7 @@ describe('ACP prompt lifecycle', () => { it('rejects an ordinary plugin failure through the same prompt boundary', async () => { harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) - harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) + harness.ctx.on('agent/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/) @@ -72,7 +72,7 @@ describe('ACP prompt lifecycle', () => { harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true - agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }) } }) @@ -166,4 +166,40 @@ describe('ACP prompt lifecycle', () => { .resolves.toEqual({ stopReason: 'end_turn' }) await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) }) + + it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) + // A recovery policy: schedule one retry for the failed request. + let retried = false + harness.ctx.on('agent/request-error', async (_subject) => { + if (!retried) { + retried = true + return { kind: 'retry' } + } + }) + const sessionId = await newSession(harness) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('end_turn') + await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') }) + }) + + it('a failed turn with no retry still rejects, at quiescence', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] }) + let offered = 0 + harness.ctx.on('agent/request-error', async () => { offered += 1 }) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: terminal boom/) + expect(offered).toBe(1) + }) + + it('an admission-blocked prompt settles cancelled instead of hanging', async () => { + harness = await makeBridgeHarness({ script: [] }) + harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' })) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'cancelled' }) + // The blocked prompt opened no turn and streamed nothing. + expect(messageText(harness)).toBe('') + }) }) diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 6f9db27161..7c222fb6f5 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/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: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 -README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 +README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b +README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db diff --git a/packages/bash/README.md b/packages/bash/README.md index 08b3627080..e60ad9b0e4 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -6,8 +6,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index a98506a6cd..57c28b45cf 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | -| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | +| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index c7e9587289..f94d04f90e 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/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: 1668f33e8acf6d749d4d3753478c12d48a19ac3c -README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 +README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 +README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 1668f33e8a..694b7a7686 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. -The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. ## Config @@ -24,11 +24,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -42,8 +42,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. -- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. -The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 0e0a4ad41b..aa6de87df4 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 -包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 ## 配置 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 -- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 -- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -42,8 +42,7 @@ - **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 -- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 -- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 152c2db28b..bacc7ac92d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 2c25701fb9..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,17 +1,39 @@ /** - * Local-subprocess implementation of the bash executor seam. Each command runs - * as `bash -c` in its own process group; disposal kills and joins live groups. - * Execution policy belongs in `tools/pre-execute` or a sandboxing executor. + * Local implementation of the bash executor seam over the subprocess + * seam. Each command runs as `bash -c` in a managed process group spawned + * through `ctx.subprocess`; this executor owns command defaulting, deadlines + * and cause classification, the model-friendly terminal environment, and the + * model-facing stdout/stderr merge for background reads. Execution policy + * belongs in `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' + +/** + * Model-friendly environment overrides: disable colors, pagers, and + * interactive terminal features that would garble tool output (the same set + * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — + * merged first into the spawn's explicit env, so a trusted caller's own entry + * still wins; the subprocess service applies its credential scrub independently. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + TERM: 'dumb', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -32,6 +54,16 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -39,10 +71,15 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor with bounded output, spill files, and process-group - * `SIGTERM` to `SIGKILL` escalation. + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the subprocess service's + * mechanics; this executor supplies their configured budgets per spawn, so a + * still-running background process stays managed (killed and joined at + * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { + static inject = ['subprocess'] + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), @@ -52,11 +89,6 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) - /** Live processes retained only so disposal can kill and join them. */ - private live = new Map() - /** Test seam: spill knobs forwarded to runBash. */ - internals: RunInternals = {} - /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig @@ -69,17 +101,6 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) - ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. - const pending: Promise[] = [] - for (const [proc, running] of this.live) { - proc.status = 'killed' - running.kill() - pending.push(proc.done) - } - this.live.clear() - await Promise.all(pending) - }, 'local bash teardown') } /** @@ -105,7 +126,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. run.ts owns the scrub and merge order. + // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -116,41 +137,71 @@ export class LocalBashExecutor extends BashExecutor { } } + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: ['bash', '-c', spec.command], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + // One explicit env map for the seam, layered so the trusted dshEnv + // snapshot beats both the caller's env and the terminal overrides; the + // subprocess service merges the whole map after its ambient scrub. + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('bash-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: spec.stdoutMaxBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: d.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals).done + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut - return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs } + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } } start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: this.config.maxOutputBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: spec.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = LocalBashExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } let stdoutOffset = 0 let stderrOffset = 0 @@ -165,26 +216,27 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + this.onProcessDone(proc, collected.stderr.readFrom(0).text) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { - const out = running.stdout.readFrom(stdoutOffset) - const err = running.stderr.readFrom(stderrOffset) + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' const delta = out.text - + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '') + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, @@ -195,11 +247,10 @@ export class LocalBashExecutor extends BashExecutor { kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' - running.kill() + running.terminate() return true }, } - this.live.set(proc, running) return proc } diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts deleted file mode 100644 index 600e920c96..0000000000 --- a/packages/bash/bash-local/src/run.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Process plumbing for the local bash executor: detached process-group spawn, - * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer - * reacts to an abort signal; the executor owns deadlines and classifies causes. - * @module dsh-bash-local/run - */ - -import { type ChildProcessByStdio, spawn } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' - -/** - * Model-friendly environment overrides: disable colors, pagers, and - * interactive terminal features that would garble tool output (the same set - * Codex hardcodes; Claude Code achieves it via TERM=dumb). - */ -export const ENV_OVERRIDES = { - NO_COLOR: '1', - TERM: 'dumb', - PAGER: 'cat', - GIT_PAGER: 'cat', -} as const - -/** - * Credential-shaped env vars are NOT forwarded to commands (the harness's - * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or - * spill files). Same default pattern as Codex's env policy; a future config - * can whitelist specific vars when a workflow genuinely needs one. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * Build a child environment from scrubbed ambient values, terminal overrides, - * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed - * names are removed; ordinary and managed entries reject the other channel's - * namespace before `dshEnv` merges last. - * @param extra - caller entries; `DSH_*` names are rejected. - * @param dshEnv - managed entries; non-`DSH_*` names are rejected. - * @returns the environment to hand to `spawn` for the child process. - */ -export function childEnv( - extra?: Readonly>, - dshEnv?: DshEnvironment, -): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value - } - for (const key of Object.keys(extra ?? {})) { - if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) - } - } - for (const key of Object.keys(dshEnv ?? {})) { - if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) - } - } - return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } -} - -/** What to run and under which limits (resolved — no defaults in here). */ -export interface SpawnSpec { - command: string - cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ - graceMs: number - /** - * Abort signal — kills the process group when it fires. The executor owns - * timing: `run()` passes a fused timeout/cancel deadline signal (see - * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal. - * runBash only listens and kills; it does NOT classify why (the executor - * reads the signal's reason afterward). - */ - signal?: AbortSignal | undefined - /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); - * the model-facing `dsh-tool-bash` tool does not thread model input here. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the credential scrub and - * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. - */ - env?: Record | undefined - /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ - dshEnv?: DshEnvironment | undefined -} - -/** - * Raw outcome of one closed process (before result shaping). Deliberately - * carries NO timeout/cancel classification: runBash kills on abort but does not - * decide why — the executor's `run()`/`start()` reads the deadline signal it - * owns to classify `timedOut`/`aborted` (see the package README). - */ -export interface SpawnOutcome { - exitCode: number | null - signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput -} - -/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ -export interface RunInternals { - /** Directory for spill files (defaults to the OS temp dir). */ - spillDir?: string -} - -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ -export const DEFAULT_GRACE_MS = 3_000 - -/** Default per-stream spill cap (the `maxSpillBytes` config). */ -export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 - -let spillCounter = 0 -let defaultSpillDir: string | undefined - -/** - * The default spill location: a private (0700) per-process directory under - * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. - */ -function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-')) - return defaultSpillDir -} - -/** - * Collects one stream with a bounded in-memory tail. On first overflow a - * spill file is created and every chunk (including those already collected) - * is appended there while the full stream remains within `maxSpillBytes`. - * - * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the - * end of command output; the spill file covers the head. - */ -export class OutputCollector { - private chunks: Buffer[] = [] - private bytes = 0 - private dropped = false - private spillFd: number | undefined - private spillFile: string | undefined - private spillDisabled = false - /** Total bytes ever pushed (not just retained). */ - private total = 0 - - constructor( - private readonly maxBytes: number, - private readonly maxSpillBytes: number, - private readonly label: string, - private readonly spillDir: string, - ) {} - - /** - * Ingest one stream chunk, counting it toward the whole-stream total. On - * first overflow of the in-memory cap a spill file is opened and every chunk - * (already-collected ones included) is appended there from then on; the - * in-memory tail then drops whole chunks from its head (or the head of a - * single over-cap chunk) until it fits the cap again. - * @param chunk - the raw bytes from one stream 'data' event. - */ - push(chunk: Buffer): void { - this.total += chunk.length - const overflows = this.bytes + chunk.length > this.maxBytes - if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) - this.chunks.push(chunk) - this.bytes += chunk.length - while (this.bytes > this.maxBytes && this.chunks.length > 1) { - // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so - // the retained tail tracks the cap closely enough for a model-facing - // truncation boundary. (length > 1 was just checked — shift() returns.) - const head = this.chunks.shift() as Buffer - this.bytes -= head.length - this.dropped = true - } - if (this.bytes > this.maxBytes && this.chunks.length === 1) { - // A single chunk larger than the cap: keep its tail. - const only = this.chunks[0] as Buffer - this.chunks[0] = only.subarray(only.length - this.maxBytes) - this.bytes = this.maxBytes - this.dropped = true - } - } - - /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ - private spillAll(chunk: Buffer): void { - if (this.total > this.maxSpillBytes) { - this.discardSpill() - return - } - if (this.spillFd === undefined) { - // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any - // existing path, symlink or not) + owner-only mode: defeats spill-path - // prediction and symlink planting in shared tmp dirs. - this.spillFile = join( - this.spillDir, - `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, - ) - this.spillFd = openSync(this.spillFile, 'wx', 0o600) - for (const prior of this.chunks) writeSync(this.spillFd, prior) - } - writeSync(this.spillFd, chunk) - } - - /** Stop spilling and remove the file once it can no longer hold the complete stream. */ - private discardSpill(): void { - const fd = this.spillFd - const file = this.spillFile - this.spillFd = undefined - this.spillFile = undefined - this.spillDisabled = true - if (fd !== undefined) { - try { - closeSync(fd) - } catch { - // Retain the descriptor so finalize can retry the failed close. - this.spillFd = fd - } - } - if (file !== undefined) { - try { - unlinkSync(file) - } catch { - // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. - } - } - } - - /** - * Incremental read in whole-stream byte coordinates: returns everything - * pushed since `fromByte`. When `fromByte` has already slid out of the - * in-memory tail window, the read is `lossy` — it returns the whole - * retained tail and the gap is only recoverable from the spill file. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). - * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created. - */ - readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } { - const windowStart = this.total - this.bytes - const buffer = Buffer.concat(this.chunks) - const lossy = fromByte < windowStart - const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart) - return { - text: slice.toString('utf8'), - nextOffset: this.total, - lossy, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } - - /** - * Close the spill file (if any) and return the final output. A failed close - * (delayed writeback fault) stops advertising the spill path — the file may - * be missing its tail — but still returns the in-memory result. - * @returns the final collected output: tail text, truncation flag, and the spill path when intact. - */ - finalize(): CollectedOutput { - if (this.spillFd !== undefined) { - try { - closeSync(this.spillFd) - } catch { - // A delayed writeback failure makes the spill unreliable; keep finalize - // total but stop advertising that file. - this.spillFile = undefined - } - this.spillFd = undefined - } - return { - text: Buffer.concat(this.chunks).toString('utf8'), - truncated: this.dropped, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } -} - -/** - * Send `sig` to a detached process group. Never throws: delivery races process - * exit and may run in a timer callback, so failures are contained and a - * non-positive pid is a no-op. - * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. - * @param sig - the signal to deliver to the whole group. - */ -export function killGroup(pid: number, sig: NodeJS.Signals): void { - if (pid <= 0) return - try { - process.kill(-pid, sig) - } catch { - // Swallow: see contract above. - } -} - -/** - * A live bash child process: the promise resolves when the process closes; - * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group. - */ -export interface RunningBash { - /** Process id (group leader); -1 when the spawn itself failed. */ - readonly pid: number - /** stdout/stderr collectors (live — background polling reads incrementally). */ - readonly stdout: OutputCollector - readonly stderr: OutputCollector - /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void -} - -/** - * Spawn one isolated `bash -c` process group and collect its output. - * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject. - * @param spec - fully resolved command, cwd, limits, and cancellation. - * @param internals - test-only process and spill-directory overrides. - * @returns live process handle and outcome promise. - */ -// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. -export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { - const spillDir = internals.spillDir ?? privateSpillDir() - - if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) - } - - // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env, spec.dshEnv) - const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - - const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) - child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) - child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) - - let graceTimer: NodeJS.Timeout | undefined - - // Failed spawns use pid -1 so kill remains a no-op. - const pid = child.pid ?? -1 - - const kill = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) - } - - // The executor owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { kill() } - spec.signal?.addEventListener('abort', onAbort, { once: true }) - - // Stdin writes are best-effort; process exit and captured output remain authoritative. - if (child.stdin !== null) { - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin) - } - - const done = new Promise((resolve, reject) => { - let settled = false - let pipeDrainTimer: NodeJS.Timeout | undefined - const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { - if (settled) return - settled = true - child.stdout.destroy() - child.stderr.destroy() - cleanup() - resolve({ - exitCode, - signal, - stdout: stdout.finalize(), - stderr: stderr.finalize(), - }) - } - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. - settled = true - cleanup() - reject(error) - }) - child.on('exit', (exitCode, signal) => { - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) - }) - child.on('close', settle) - function cleanup(): void { - if (graceTimer !== undefined) clearTimeout(graceTimer) - if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) - spec.signal?.removeEventListener('abort', onAbort) - } - }) - - return { pid, stdout, stderr, done, kill } -} diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2e24addb3b..1395b48958 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,16 +4,18 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } return { ctx, bash } } @@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('LocalBashExecutor disposal', () => { - it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => { +describe('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } // The child prints its own pid ($$ = the detached bash group leader) so // the test can probe liveness through the public read surface alone. - const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' })) + const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' })) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - await fiber.dispose() - // Disposal itself waited: the pid must already be gone, no grace left — - // even for a TERM-trapping child held until the SIGKILL escalation landed. + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() - expect(proc.status).toBe('killed') await proc.done + expect(proc.status).toBe('killed') }) - it('settled processes already left the live map: dispose does not touch them', async () => { + it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'echo done' })) await finished.done expect(finished.status).toBe('completed') - const running = bash.start(bash.resolve({ command: 'sleep 60' })) + const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })) + await readUntil(trapping, 'armed') - await fiber.dispose() - // The teardown marks every LIVE entry killed; a settled process had - // already left the map, so its status stays completed. + await managerFiber.dispose() + // A settled process was untouched; the live one died by escalation. expect(finished.status).toBe('completed') - expect(running.status).toBe('killed') - await running.done - expect(running.signal).toBe('SIGTERM') + await trapping.done + expect(trapping.status).toBe('killed') + expect(trapping.signal).toBe('SIGKILL') }) }) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts deleted file mode 100644 index 91afd1aede..0000000000 --- a/packages/bash/bash-local/tests/run.spec.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-bash' -import { killGroup, OutputCollector, runBash } from '../src/run.ts' -import type { RunningBash } from '../src/run.ts' - -const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ - failNextClose: { value: false }, - failNextUnlink: { value: false }, -})) -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - closeSync(fd: number): void { - if (failNextClose.value) { - failNextClose.value = false - throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' }) - } - actual.closeSync(fd) - }, - unlinkSync(path: Parameters[0]): void { - if (failNextUnlink.value) { - failNextUnlink.value = false - throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) - } - actual.unlinkSync(path) - }, - } -}) - -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) - -function spec(command: string, overrides: Partial[0]> = {}) { - return { - command, - cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, - graceMs: 3_000, - ...overrides, - } -} - -/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */ -async function waitGone(pid: number, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) -} - -async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (running.stdout.readFrom(0).text.includes(expected)) return - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) -} - -async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - const pid = Number(readFileSync(path, 'utf8').trim()) - if (Number.isSafeInteger(pid) && pid > 0) return pid - } catch { - // The child shell has not written the pid file yet. - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) -} - -describe('runBash', () => { - it('captures stdout on success', async () => { - const result = await runBash(spec('echo hello')).done - expect(result.exitCode).toBe(0) - expect(result.signal).toBeNull() - expect(result.stdout.text).toBe('hello\n') - expect(result.stdout.truncated).toBe(false) - expect(result.stderr.text).toBe('') - }) - - it('captures stderr separately', async () => { - const result = await runBash(spec('echo oops >&2')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - expect(result.stderr.text).toBe('oops\n') - }) - - it('captures both streams', async () => { - const result = await runBash(spec('echo out; echo err >&2')).done - expect(result.stdout.text).toBe('out\n') - expect(result.stderr.text).toBe('err\n') - }) - - it('reports non-zero exit codes', async () => { - const result = await runBash(spec('exit 42')).done - expect(result.exitCode).toBe(42) - expect(result.signal).toBeNull() - }) - - it('applies model-friendly env overrides', async () => { - const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done - expect(result.stdout.text).toBe('1/dumb/cat\n') - }) - - it('runs in the requested cwd', async () => { - const result = await runBash(spec('pwd', { cwd: '/tmp' })).done - expect(result.stdout.text.trim()).toMatch(/\/tmp$/) - }) - - it('kills the process group with SIGTERM when the signal fires', async () => { - // runBash owns no timer: it kills on abort. The executor drives the timeout - // by firing this signal via a deadline (see executor.spec.ts); here we - // assert the kill itself lands as SIGTERM. - const controller = new AbortController() - const start = Date.now() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('deadline') }, 100) - const result = await running.done - expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() - }) - - it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) - await waitForStdout(running, 'ready\n') - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the - // group must take the sleep down with bash. - const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) - const grandchild = await waitForPidFile(pidFile) - expect(grandchild).toBeGreaterThan(0) - - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await waitGone(grandchild) - }) - - it('aborts via AbortSignal mid-run', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('user cancelled') }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('throws when the signal is already aborted before spawn', () => { - const controller = new AbortController() - controller.abort('too late') - expect(() => runBash(spec('echo hi', { signal: controller.signal }))) - .toThrow(/aborted before spawn: too late/) - }) - - it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) - .rejects.toThrow(/ENOENT/) - }) - - it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = runBash(spec('sleep 60')) - running.kill() - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('bounds inherited-pipe draining after the shell exits', async () => { - const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) - const started = Date.now() - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) - const descendant = await waitForPidFile(pidFile) - try { - const result = await running.done - expect(Date.now() - started).toBeLessThan(1_000) - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('shell-done\n') - } finally { - process.kill(descendant, 'SIGKILL') - await waitGone(descendant) - } - }) -}) - -describe('stdin and extra env (set by in-process plugins)', () => { - it('writes stdin to the command and closes it', async () => { - const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('hello from stdin\n') - }) - - it('a command that reads stdin sees EOF when none is supplied', async () => { - // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no - // output (it does NOT block). - const result = await runBash(spec('cat')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - }) - - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { - // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). - // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done - expect(none.stdout.text).toBe('char\n') - const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done - expect(piped.stdout.text).toBe('socket\n') - }) - - it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { - env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, - })).done - expect(result.stdout.text).toBe('alpha/beta\n') - }) - - it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { - // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. - // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit - // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, - })).done - expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') - }) - - it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { - // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. - // The handler swallows that write error and `done` reports the child's real exit. - const big = 'x'.repeat(1024 * 1024) - const result = await runBash(spec('exit 7', { stdin: big })).done - expect(result.exitCode).toBe(7) - }) -}) - -describe('output truncation and spill', () => { - it('applies stdout and stderr caps independently', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { - stdoutMaxBytes: 500, - stderrMaxBytes: 100, - }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text).toBe('x'.repeat(500)) - expect(result.stderr.truncated).toBe(true) - expect(result.stderr.text.length).toBeLessThanOrEqual(100) - }) - - it('keeps the tail and spills the full stream to disk', async () => { - // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text.length).toBeLessThanOrEqual(500) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.text).not.toContain('line-0001') - expect(result.stdout.spillPath).toBeDefined() - const full = readFileSync(result.stdout.spillPath!, 'utf8') - expect(full).toContain('line-0001') - expect(full).toContain('line-0200') - }) - - it('does not truncate output exactly at the cap', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text.length).toBe(500) - expect(result.stdout.spillPath).toBeUndefined() - }) - - it('settles with the tail and no spill path when final spill close fails', async () => { - failNextClose.value = true - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(failNextClose.value).toBe(false) - expect(result.exitCode).toBe(0) - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.spillPath).toBeUndefined() - }) -}) - -describe('OutputCollector', () => { - it('keeps the tail of a single oversized chunk', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('0123456789abcdef')) - const out = collector.finalize() - expect(out.text).toBe('6789abcdef') - expect(out.truncated).toBe(true) - expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') - }) - - it('readFrom returns increments and flags lossy reads', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('aaaaa')) - const first = collector.readFrom(0) - expect(first.text).toBe('aaaaa') - expect(first.lossy).toBe(false) - expect(first.nextOffset).toBe(5) - - collector.push(Buffer.from('bbbbb')) - const second = collector.readFrom(first.nextOffset) - expect(second.text).toBe('bbbbb') - expect(second.lossy).toBe(false) - - // Push enough to slide the window past the last offset. - collector.push(Buffer.from('c'.repeat(20))) - const third = collector.readFrom(second.nextOffset) - expect(third.lossy).toBe(true) - expect(third.text).toBe('c'.repeat(10)) - expect(third.spillPath).toBeDefined() - }) - - it('contains close failures and drops the spill path', () => { - const collector = new OutputCollector(4, 100, 'closefail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - expect(collector.readFrom(0).spillPath).toBeDefined() - - failNextClose.value = true - let out: ReturnType - expect(() => { out = collector.finalize() }).not.toThrow() - - expect(failNextClose.value).toBe(false) - expect(out!.text).toBe('bbbb') - expect(out!.truncated).toBe(true) - expect(out!.spillPath).toBeUndefined() - }) - - it('discards a spill that exceeds its configured cap', () => { - const collector = new OutputCollector(4, 8, 'bounded', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') - - collector.push(Buffer.from('c')) - collector.push(Buffer.from('dddd')) - const out = collector.finalize() - expect(out.text).toBe('dddd') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - expect(() => readFileSync(spillPath)).toThrow() - }) - - it('does not create a spill when the first overflowing chunk exceeds the cap', () => { - const collector = new OutputCollector(4, 4, 'no-spill', spillDir) - collector.push(Buffer.from('abcdefgh')) - const out = collector.finalize() - expect(out.text).toBe('efgh') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - }) - - it('contains cleanup failures while disabling an oversize spill', () => { - const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - - failNextClose.value = true - failNextUnlink.value = true - expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() - expect(failNextClose.value).toBe(false) - expect(failNextUnlink.value).toBe(false) - expect(collector.finalize().spillPath).toBeUndefined() - unlinkSync(spillPath) - }) -}) - -describe('killGroup', () => { - it('ignores non-positive pids', () => { - expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow() - expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow() - }) - - it('swallows ESRCH for vanished groups', async () => { - const running = runBash(spec('true')) - await running.done - expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() - }) -}) - -describe('abort edge cases', () => { - it('reports a fallback reason for reason-less pre-aborted signals', () => { - // Real AbortControllers always set a DOMException reason; signal-like - // objects from other libraries may not — the fallback covers them. - const bare = { - aborted: true, - reason: undefined, - addEventListener() {}, - removeEventListener() {}, - } as unknown as AbortSignal - expect(() => runBash(spec('echo hi', { signal: bare }))) - .toThrow(/aborted before spawn: aborted/) - }) - - it('reports the terminating signal of an externally self-killed command', async () => { - // runBash reports the raw signal; whether it counts as timeout/cancel is the - // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await runBash(spec('kill -TERM $$')).done - expect(result.signal).toBe('SIGTERM') - }) -}) - -describe('environment and spill-file hardening', () => { - it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { - process.env.DSH_TEST_API_KEY = 'super-secret' - process.env.DSH_TEST_TOKEN = 'also-secret' - process.env.DSH_TEST_PLAIN = 'visible' - try { - const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done - expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') - } finally { - delete process.env.DSH_TEST_API_KEY - delete process.env.DSH_TEST_TOKEN - delete process.env.DSH_TEST_PLAIN - } - }) - - it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { - process.env.DSH_STALE = 'old-value' - try { - const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { - dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, - })).done - expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') - } finally { - delete process.env.DSH_STALE - } - }) - - it('rejects DSH variables on the ordinary env channel', () => { - expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) - .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) - }) - - it('rejects ordinary variables on the managed env channel', () => { - const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => runBash(spec('true', { dshEnv: invalid }))) - .toThrow(/managed bash env.*PATH.*use env/) - }) - - it('creates spill files with owner-only permissions and random names', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) - const mode = statSync(path).mode & 0o777 - expect(mode).toBe(0o600) - }) - - it('defaults spills into a private per-process directory', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - ).done - const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-bash-/) - const mode = statSync(dir).mode & 0o777 - expect(mode).toBe(0o700) - }) - - it('killGroup never throws, even for EPERM-style failures', () => { - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) - }) - try { - expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow() - } finally { - spy.mockRestore() - } - }) - - it('honors AbortSignal on background-style runs (no timeout)', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort() }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) -}) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index a55c76f00a..53ccc94926 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../bash/bash" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 9faf613c71..0f2240630c 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index b889692f3c..3945809fa9 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox', 'sandboxPolicy'] + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns + * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 87bcffe9df..437078440c 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) 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 3ce944b07c..0c5cfbe563 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,6 +9,7 @@ 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' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) 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 6e8f2229a0..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -58,9 +59,10 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor - bash.internals = { spillDir } return { ctx, bash, calls } } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 6c212ee546..62b1569ee7 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index f32efb60a0..520828acde 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/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: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 -README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4ee66a1fa..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp 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). +`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, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. 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). ## Model Experience diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 151d4bd7ab..14476b7703 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 93497df719..b8ff310f01 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index d9eedee052..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -43,7 +43,10 @@ declare module 'cordis' { * failures settle as `killed` with the error on stderr. * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. - * - Disposal kills all running background processes and awaits their exit. + * - A still-running background process is stopped and awaited when its + * owning composition tears down. With the subprocess seam that + * boundary is `ctx.subprocess` disposal, so a background process survives + * an executor-only reload. */ export abstract class BashExecutor extends Service { constructor(ctx: Context) { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index a504513417..6fdd8b36c7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -1,19 +1,17 @@ /** * Execution types for the bash executor seam. Background task semantics belong - * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. + * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The + * managed-environment and captured-output vocabulary is owned by the + * subprocess seam and re-exported here so bash consumers keep one import + * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' -/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ -export const DSH_ENV_PREFIX = 'DSH_' as const - -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` - -/** Trusted DeepSeek Harness variables for one bash execution. */ -export type DshEnvironment = Readonly> +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -62,17 +60,18 @@ export interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -99,27 +98,17 @@ export interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined } -/** One captured stream: the (possibly truncated) text plus recovery info. */ -export interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} - /** The outcome of one completed (or killed) foreground run. */ export interface BashRunResult { /** Exit code; null when the process died from a signal. */ @@ -165,8 +154,9 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { /** Process lifecycle state (settled exactly once). */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 51175e6080..3f611c80e0 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a89e147e0e..c34e1c6e7b 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index e1315c232a..fcfaae08d4 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) @@ -109,11 +111,12 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.followup([{ type: 'text', text: 'inspect the current session' }]) + agent.followup({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) + await ctx.sessions.flush(agent.session) expect(existsSync(location!.path)).toBe(true) const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) @@ -128,7 +131,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const log = events(agent) @@ -160,7 +163,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run exit 9' }]) + agent.followup({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -180,7 +183,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -200,7 +203,7 @@ describe('bash tool through the agent loop', () => { expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.followup([{ type: 'text', text: 'collect it' }]) + agent.followup({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 80840fbf75..15222ee647 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -32,8 +33,9 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -46,8 +48,9 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -275,8 +278,9 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -383,6 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -400,6 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -411,6 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 64b6577c26..5c7befd8c9 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -775,6 +775,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + pickDirectory: request => ok(request, { path: null }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -1025,6 +1026,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f06b2bd2f6..bc73e0e054 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,6 +5,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' +import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' export { API_PATH } from './api-path.ts' @@ -23,7 +24,16 @@ export function apply(ctx: Context): void { const route: WebRoute = { kind: 'prefix', path: API_PATH, - handler: (req, res) => bridge(req, res, apiHandler), + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + if (pathname === `${API_PATH}/host.pickDirectory` + && !isTrustedNativeDialogRequest(req)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, apiHandler) + }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') } diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts new file mode 100644 index 0000000000..fe91bbae2d --- /dev/null +++ b/packages/client/connection/src/native-dialog-request.ts @@ -0,0 +1,52 @@ +/** Trust check for browser requests that can open an operating-system dialog. */ + +import type { IncomingHttpHeaders } from 'node:http' + +interface NativeDialogRequest { + headers: IncomingHttpHeaders + socket: { remoteAddress?: string | undefined } +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopback(address: string | undefined): boolean { + if (address === undefined) return false + if (address === '::1') return true + const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address + const first = ipv4.split('.')[0] + return first === '127' +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** + * Require a local socket plus browser-controlled same-origin metadata. + * @param request - the node HTTP request facts used by the carrier guard. + * @returns true only for a same-origin browser request whose peer and URL are loopback. + */ +export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { + if (!isLoopback(request.socket.remoteAddress)) return false + if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false + const origin = header(request.headers, 'origin') + const host = header(request.headers, 'host') + if (origin === undefined || host === undefined) return false + try { + const parsed = new URL(origin) + const hostUrl = new URL(`http://${host}`) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') + && parsed.host === host + && isLoopbackHostname(parsed.hostname) + && isLoopbackHostname(hostUrl.hostname) + } catch { + return false + } +} diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 36a98f1580..1bb49b19fb 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -64,6 +64,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -85,6 +87,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts new file mode 100644 index 0000000000..4607f32bae --- /dev/null +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { describe, expect, it } from 'vitest' +import { bridge } from '../src/http-bridge.ts' + +describe('HTTP bridge abort', () => { + it('aborts a pending native picker request when the browser disconnects', async () => { + const body = JSON.stringify({ + type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + }) + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/host.pickDirectory', + method: 'POST', + headers: { 'content-type': 'application/json' }, + }) + + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { return true }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + let carrierSignal: AbortSignal | undefined + const pending = bridge(request, response, { + fetch: async (input) => { + const fetchRequest = input as Request + carrierSignal = fetchRequest.signal + resolveStarted() + if (!fetchRequest.signal.aborted) { + await new Promise((resolve) => { + fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return Response.json({ aborted: fetchRequest.signal.aborted }) + }, + }) + await started + response.emit('close') + await pending + expect(carrierSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts new file mode 100644 index 0000000000..1a3d70dd15 --- /dev/null +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -0,0 +1,57 @@ +import type { IncomingHttpHeaders } from 'node:http' +import { describe, expect, it } from 'vitest' +import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' + +function request( + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { + host: '127.0.0.1:3080', + origin: 'http://127.0.0.1:3080', + 'sec-fetch-site': 'same-origin', + }, +) { + return { socket: { remoteAddress }, headers } +} + +describe('native dialog request trust', () => { + it('accepts loopback same-origin browser requests', () => { + expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::1', { + host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.2', { + host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + }) + + it('rejects remote sockets and requests without matching browser metadata', () => { + expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e9e880cfb4..86af61ba0d 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' @@ -27,6 +28,23 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + let status: number | undefined + let body: unknown + const deniedRequest = { + url: '/api/host.pickDirectory', + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + await fiber.dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b4a418628f..ec39ce9e1c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspacesService } from './workspaces/service.ts' +export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index e1b2c42962..8cd57c4eb3 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -87,7 +87,6 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown - meta?: unknown } /** A tool result paired (when in-window) with its call head. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..d72c1af8e3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -46,7 +46,6 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, - meta: event.data.meta, } } return { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 4cb26aa220..fe345801c6 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -21,6 +21,14 @@ export interface WorkspaceListState { recentWorkspaceId: WorkspaceId | undefined } +/** Structured create failure for UI flows that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + constructor(readonly rpcError: RpcError) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'WorkspaceCreateError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -37,7 +45,7 @@ export class WorkspacesService { * @param api - shared wire client. * @param sessions - lower-level Session service used for recency and blank-session reuse. */ - constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ items: [], state: 'idle', phase: 'pending', error: null, @@ -158,10 +166,22 @@ export class WorkspacesService { */ async create(input: { name: string } | { path: string }): Promise { const result = await this.manager.create(input) - if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace } + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + async pickDirectory(): Promise { + const response = await this.api.host.pickDirectory({}) + if (!response.result.ok) { + throw new Error(`directory picker failed: ${response.result.error.message}`) + } + return response.result.value.path + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 7192d03d98..a5eecd0cf5 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -82,6 +82,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -103,6 +105,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 360f7c1a9d..6fe9f33c80 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -20,7 +20,8 @@ const rid = (id: string): RpcId => id as RpcId function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { return { type: 'session/queued', sessionId: SID, content: text(body), - source: { kind: 'user', rpcId: rid(rpcId) } as never, steering, + source: { kind: 'user', rpcId: rid(rpcId) } as never, + steering, } } @@ -41,7 +42,8 @@ describe('queue intake', () => { session.handleMuxEnvelope(rid('env-2'), { type: 'session/queued', sessionId: SID, content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - source: { kind: 'plugin', plugin: 'loop' }, steering: false, + source: { kind: 'plugin', plugin: 'loop' }, + steering: false, }) expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) }) @@ -85,22 +87,22 @@ describe('queue retirement (host queuedMirror rules)', () => { it('steering/message drains the source-matched steering row only', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true)) + session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering + session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true)) // Loop-authored steering (different source) must not consume the user entry. const foreignSteering = { seq: 0, time: 1, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } }, } as never - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering }) + session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering }) expect(session.getSnapshot().queue).toHaveLength(2) const matchedSteering = { seq: 1, time: 2, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } }, } as never - session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering }) + session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering }) expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) }) @@ -108,7 +110,7 @@ describe('queue retirement (host queuedMirror rules)', () => { const session = makeSession() session.handleRunning(true) session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true)) + session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2')) session.handleRunning(false) expect(session.getSnapshot().queue).toEqual([]) }) @@ -144,6 +146,19 @@ describe('queue reconnect semantics', () => { await session.resync() expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) }) + + it('replayed steering retires without a replayed turn/start', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) + session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true)) + const committed = { + seq: 6, time: 2, + type: 'steering/message', surfaceOp: 'append', + data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } }, + } as never + session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed }) + expect(session.getSnapshot().queue).toEqual([]) + }) }) describe('manager buffering of queued frames', () => { diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 210c04d896..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -210,12 +210,30 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(ok({ + workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, + })) + await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' }) + expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }]) api.onWorkspaceCreate = () => Promise.resolve(err({ code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, })) - await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + const rejected = workspaces.create({ path: '/missing' }) + await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/) + await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError) + }) + + it('passes native directory selection and cancellation through without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') + api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + await expect(workspaces.pickDirectory()).resolves.toBeNull() + expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) }) it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 9afd432d37..bda650e190 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial> = {}, consumeResu } function rowLabels(): string[] { - return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!) + return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent) } describe('PopupSelectView', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4353ef87e7..c1a6828d5b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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 packages/client/ui-conversation/README.md -README.md: 78e3930f2654ecb18bf5adf640bc16721fe76595 -README.zh.md: 1c2fd0d7462d472ba101723cce6d3e50ca6ea476 +README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739 +README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 78e3930f26..56a445ccfa 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1c2fd0d746..a7c160ecdd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7a229758b1..789f86aeb6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -90,20 +90,17 @@ export function apply(ctx: Context): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ - selectWorkspace: (workspaceId) => { - void workspaces.connectWorkspace(workspaceId).then((nextId) => { - if (sessionId !== undefined && nextId !== sessionId) { - const from = inputHub.shell(sessionId) - const draft = from.snapshot.draft - if (draft !== '') { - inputHub.shell(nextId).setDraft(draft) - from.setDraft('') - } + selectWorkspace: async (workspaceId) => { + const nextId = await workspaces.connectWorkspace(workspaceId) + if (sessionId !== undefined && nextId !== sessionId) { + const from = inputHub.shell(sessionId) + const draft = from.snapshot.draft + if (draft !== '') { + inputHub.shell(nextId).setDraft(draft) + from.setDraft('') } - sessions.open(nextId) - }).catch(() => { - // Failure leaves the current Hero state available to retry. - }) + } + sessions.open(nextId) }, }), }, ConversationRoot) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3b2c1b9ef0..f61c6da6ef 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -86,7 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq seq: number onOpenDetails: OpenDetails selected: boolean - /** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */ + /** `run_code` sub-dispatches in dispatch order (reference-stable per + * parent; running entries settle in place); undefined for ordinary calls. */ subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined @@ -103,7 +104,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq })} {subCalls !== undefined && subCalls.length > 0 && (
- {subCalls.map((node) => ( + {subCalls.map(node => ( - {results.map((node) => ( + {results.map(node => ( void }) { - const partial = useSession((s) => s.partial) + const partial = useSession(s => s.partial) useLayoutEffect(() => { onGrow() }) @@ -162,17 +163,20 @@ function StreamingTail({ useSession, onGrow }: { return } -/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ +/** + * The chat view slot entry: pure component over the composed props (tool rows + * render through the declared keyed hole's renderSlot share). + */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { - const nodes = useSession((s) => s.nodes) - const runningCalls = useSession((s) => s.runningCalls) - const codeDispatches = useSession((s) => s.codeDispatches) - const pending = useSession((s) => s.pending) - const openState = useSession((s) => s.openState) - const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) - const hasMore = useSession((s) => s.hasMore) - const loadingOlder = useSession((s) => s.loadingOlder) - const selectedCallId = useStore((s) => s.selection?.callId) + const nodes = useSession(s => s.nodes) + const runningCalls = useSession(s => s.runningCalls) + const codeDispatches = useSession(s => s.codeDispatches) + const pending = useSession(s => s.pending) + const openState = useSession(s => s.openState) + const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) + const hasMore = useSession(s => s.hasMore) + const loadingOlder = useSession(s => s.loadingOlder) + const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) @@ -254,8 +258,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl const renderItem = (item: ChatFlowItem): ReactNode => { if (item.kind === 'tool-group') { const inGroup = selectedCallId !== undefined - && item.results.some((r) => r.callId === selectedCallId - || codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true) + && item.results.some(r => r.callId === selectedCallId + || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true) return (
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} - {hasMore && ( -
- -
- )} - {items.map(renderItem)} - - {runningCalls.length > 0 && ( -
- {runningCalls.map((call) => ( - - ))} -
- )} - {pending.map((item) => )} + {openState === 'loading' &&
载入历史…
} + {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {hasMore && ( +
+ +
+ )} + {items.map(renderItem)} + + {runningCalls.length > 0 && ( +
+ {runningCalls.map(call => ( + + ))} +
+ )} + {pending.map(item => )}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 7dbefdc139..5cb2126f34 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr return ( { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text) @@ -40,6 +43,9 @@ async function writeClipboard(text: string): Promise { } return } + // execCommand('copy') is the only clipboard fallback where the async API + // is missing (insecure contexts); deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined @@ -56,6 +62,7 @@ async function writeClipboard(text: string): Promise { } catch { // Clipboard unavailable; the button stays idle. } + /* eslint-enable @typescript-eslint/no-deprecated */ el.remove() } @@ -146,7 +153,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) case 'context': return (
- +
) default: diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 50dead9529..45b783f19b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { export interface StatsLineProps { useSession: SnapshotSelectorHook } export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { - const nodes = useSession((s) => s.nodes) + const nodes = useSession(s => s.nodes) const stats = useMemo(() => deriveStats(nodes), [nodes]) if (stats.steps === 0) return null const parts: string[] = [] diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 16878ae91e..5d44a9260e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -42,6 +42,21 @@ color: var(--dsw-alias-label-secondary); } +/* Cordis lifecycle tools retain their generic row mechanics while carrying a + shared product accent and tool-owned action title. */ +.root[data-tool^='cordis_'] .leading, +.root[data-tool^='cordis_'] .title { + color: var(--dsw-alias-state-business-primary); +} + +.root[data-tool^='cordis_'] .title { + font-weight: 500; +} + +.root[data-tool^='cordis_'] .sep { + background: var(--dsw-alias-state-business-primary); +} + button.leading { cursor: pointer; } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 113241eb5d..a81c084b8e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -13,6 +13,8 @@ import css from './ToolRow.module.css' export interface ToolRowProps { variant: ToolRowVariant + /** Wire tool name for tool-owned styling layered over the generic variant. */ + toolName?: string | undefined /** Leading 16px tool icon, shown while collapsed and not running/failed. */ icon: ReactNode title: string @@ -39,6 +41,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { export function ToolRow({ variant, + toolName, icon, title, summary, @@ -52,7 +55,7 @@ export function ToolRow({ const open = expanded && expandable const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { - setExpanded((v) => !v) + setExpanded(v => !v) } const toggleFromLeading = (event: MouseEvent) => { event.stopPropagation() @@ -64,7 +67,7 @@ export function ToolRow({ toggleExpand() } return ( -
+
void } /** @@ -175,21 +175,21 @@ export interface ConversationInjected { * Connect the selected Workspace and open its reusable/new blank session. * When a blank session is already current, carry its draft to the target. */ - selectWorkspace(workspaceId: WorkspaceId): void + selectWorkspace: (workspaceId: WorkspaceId) => Promise } /** Business callbacks injected into the strict session content seat. */ export interface ConversationSessionInjected { /** Views projected from the `conversation.view` slot ledger. */ views: { - list(): readonly ViewTab[] - subscribe(fn: () => void): () => void - version(): number + list: () => readonly ViewTab[] + subscribe: (fn: () => void) => () => void + version: () => number } /** Bind the input machine's draft persistence mirror to the session store. */ - bindDraftMirror(write: (text: string) => void): () => void + bindDraftMirror: (write: (text: string) => void) => () => void /** Select a real Session through the runtime navigation owner. */ - open(sessionId: SessionId): void + open: (sessionId: SessionId) => void } /** @@ -219,7 +219,7 @@ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ - stop(): void + stop: () => void } /** @@ -275,8 +275,8 @@ export type ConversationSessionSlotProps = */ export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ - openDetails(target: SelectionTarget): void - loadOlder(): void + openDetails: (target: SelectionTarget) => void + loadOlder: () => void } /** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */ @@ -290,7 +290,7 @@ export type ChatViewSlotProps = */ export interface DetailsInjected { /** Close the details panel (layout geometry stays with ctx.layout). */ - closeDetails(): void + closeDetails: () => void } /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ @@ -300,6 +300,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject - onPick(workspaceId: WorkspaceId): void - onClose(): void + onPick: (workspaceId: WorkspaceId) => void + onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 1c5996580f..5b725df00b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -36,6 +36,16 @@ const TOOL_VARIANTS: Record = { write: 'write', edit: 'edit', run_code: 'code', + cordis_inspect: 'read', + cordis_mount: 'code', + cordis_unmount: 'others', +} + +/** Tool-owned titles that refine a generic row variant without replacing it. */ +const TOOL_TITLES: Record = { + cordis_inspect: 'Inspect', + cordis_mount: 'Mount temporary Plugin', + cordis_unmount: 'Unmount temporary Plugin', } /** @@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool - // name rides the mutable summary slot so no information is lost. - const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base + // name rides the mutable summary slot unless the tool owns a specific title. + const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + ? `${toolName} · ${base}` + : base return { variant, - title: VARIANT_TITLES[variant], + title: toolTitle ?? VARIANT_TITLES[variant], summary, body: deriveBody(variant, argsRaw), state, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..d6e7492836 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,8 +2,9 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' @@ -25,8 +26,23 @@ export function ConversationRoot({ const workspaces = useWorkspaces(s => s) const [pickerOpen, setPickerOpen] = useState(false) + const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + const sessionWorkspace = sessionId === undefined + ? undefined + : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) + const pendingWorkspace = workspaces.items.find( + workspace => workspace.workspaceId === pendingWorkspaceId, + ) + + useEffect(() => { + if (pendingWorkspaceId !== undefined + && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + setPendingWorkspaceId(undefined) + } + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -36,9 +52,10 @@ export function ConversationRoot({ w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '') + : sessionWorkspace?.title ?? workspaceLabel(cwd ?? '')) } menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} @@ -48,7 +65,10 @@ export function ConversationRoot({ anchorRef: pickerAnchor, onPick: (workspaceId) => { setPickerOpen(false) - selectWorkspace(workspaceId) + setPendingWorkspaceId(workspaceId) + void selectWorkspace(workspaceId).catch(() => { + setPendingWorkspaceId(current => current === workspaceId ? undefined : current) + }) }, onClose: () => { setPickerOpen(false) }, })} @@ -58,12 +78,12 @@ export function ConversationRoot({ const inputBar = sessionId === undefined ? : renderSlot('conversation.composer.bar', { - variant: hero ? 'hero' : 'composer', - ...(hero ? { placeholder: 'Describe what you want to build' } : {}), - overlay: renderSlot('conversation.input.overlay', {}), - leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), - rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), - }) + variant: hero ? 'hero' : 'composer', + ...(hero ? { placeholder: 'Describe what you want to build' } : {}), + overlay: renderSlot('conversation.input.overlay', {}), + leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), + rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), + }) const composerBar = (
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 515bfe1f93..45d98c1693 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -41,8 +41,8 @@ export function ConversationSession({ if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) const unmirror = bindDraftMirror(actions.setDraft) return () => { unmirror() } - // Mount-only: later store writes come from the machine mirror. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Mount-only (deps pinned to inputActions): later store writes come from + // the machine mirror, not this seed effect. }, [inputActions]) if (blank && composerPhase === 'blank') return null diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 3d3c84a646..650a95f833 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane : material === null ?
该调用不在当前窗口内
: ( - <> - {material.argsRaw !== null && ( -
-
Input
- -
- )} + <> + {material.argsRaw !== null && (
-
Output
- {/* materialFor invariant: result===null ⇔ running (a settled - material always carries its result node). */} - {material.result === null - ?
运行中…
- : ( -
-                            {renderResult(material.result)}
-                          
- )} +
Input
+
- - )} + )} +
+
Output
+ {/* materialFor invariant: result===null ⇔ running (a settled + material always carries its result node). */} + {material.result === null + ?
运行中…
+ : ( +
+                        {renderResult(material.result)}
+                      
+ )} +
+ + )}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5f9a3c02d5..f2313d798b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -31,7 +31,11 @@ export function InputBar({ variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) - const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot) + const noticeStore = keyboard.notices + const notice = useSyncExternalStore( + (fn: () => void) => noticeStore.subscribe(fn), + () => noticeStore.getSnapshot(), + ) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) @@ -75,6 +79,8 @@ export function InputBar({ // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + // eslint-disable-next-line @typescript-eslint/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() @@ -92,7 +98,7 @@ export function InputBar({ // the browser stack cannot represent); never let the native stack run. e.preventDefault() if (machineBusy || locked) return - const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z')) + const redo = e.key === 'y' || e.shiftKey if (redo) keyboard.redo() else keyboard.undo() return @@ -134,6 +140,8 @@ export function InputBar({ if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) + // selectionStart is number|null in lib.dom; the eslint program narrows it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition keyboard.track(next, e.target.selectionStart ?? next.length) } @@ -145,10 +153,13 @@ export function InputBar({ // too (one char = one step). Mouse selection of a chip is handled in the // backdrop click handler below. Undo/redo must NOT reach the browser: the // machine owns the transaction log. + // selectionStart/End are number|null in lib.dom; the eslint program narrows them. + /* eslint-disable @typescript-eslint/no-unnecessary-condition */ const selectionOf = (el: HTMLTextAreaElement) => ({ start: el.selectionStart ?? 0, end: el.selectionEnd ?? el.selectionStart ?? 0, }) + /* eslint-enable @typescript-eslint/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { const el = e.currentTarget @@ -330,8 +341,8 @@ export function InputBar({ onChange={onChange} onKeyDown={onKeyDown} onSelect={onSelect} - onCopy={e => { onCopyOrCut(e, false) }} - onCut={e => { onCopyOrCut(e, true) }} + onCopy={(e) => { onCopyOrCut(e, false) }} + onCut={(e) => { onCopyOrCut(e, true) }} onPaste={onPaste} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index da255415ce..504d0ec8c6 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -36,10 +36,12 @@ const SCOPE_TAG: symbol = (() => { const spy = new Proxy(new Context(), { get(target, prop, receiver) { recorded.push(prop) + // Reflect.get is typed any; the probe only records property names. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Reflect.get(target, prop, receiver) }, }) - void scopeOf(spy as Context) + void scopeOf(spy) const symbol = recorded.find((p): p is symbol => typeof p === 'symbol') if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read') return symbol @@ -73,14 +75,15 @@ async function bench() { const mint = (id: SessionId): Context => { let scoped = scopes.get(id) if (scoped === undefined) { - scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context + scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) scopes.set(id, scoped) } return scoped } type TestProvider = { resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): { - hooks?: Record; props?: Record + hooks?: Record + props?: Record } } const providers: TestProvider[] = [] @@ -158,10 +161,12 @@ async function bench() { const inputSurface = (id: SessionId) => { const contribution = providers[0]!.resolve(sessionsFake.binding(id)) const state = contribution.hooks!['input'] as { - getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void + getSnapshot: () => { draft: string } + subscribe: (fn: () => void) => () => void } const actions = contribution.props!['inputActions'] as { - setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void + setDraft: (text: string) => void + submit: (mode?: 'queue' | 'steer') => void } return { state, actions } } @@ -234,11 +239,11 @@ describe('conversation slot inject surface', () => { const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected // Unknown session: sessions.scope answers nothing. ;(b.sessionsFake.scope as unknown) = () => undefined - expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/) + expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/) // A scope minted outside the service tree: no conversation service on it. const foreign = new Context() ;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({}) - expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/) + expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/) }) it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => { @@ -263,7 +268,7 @@ describe('conversation slot inject surface', () => { // no draft movement, plain re-open. const { state, actions } = b.inputSurface(ROOT) actions.setDraft('carry me') - resident.selectWorkspace('workspace-1' as never) + void resident.selectWorkspace('workspace-1' as never) await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) }) expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1') expect(state.getSnapshot().draft).toBe('carry me') @@ -271,7 +276,7 @@ describe('conversation slot inject surface', () => { // new session's machine receives the text, then navigation lands there. const OTHER = 'other-1' as SessionId b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER) - resident.selectWorkspace('workspace-2' as never) + void resident.selectWorkspace('workspace-2' as never) await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) }) expect(state.getSnapshot().draft).toBe('') expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me') diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d0f37e0229..36a25c5b34 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -31,7 +31,7 @@ async function bench() { }, current: undefined, phase: 'ready', - } as SessionListState) + }) const sessionsFake = { list: listStore, binding: vi.fn(), @@ -83,7 +83,7 @@ describe('apply wiring', () => { const b = await bench() await b.fiber.await() const entries = b.slots.entries('conversation.view') - expect(entries.map((e) => e.options.id)).toEqual(['chat']) + expect(entries.map(e => e.options.id)).toEqual(['chat']) expect(entries[0]?.options.label).toBe('Chat') expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on @@ -117,7 +117,7 @@ describe('apply wiring', () => { // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) }) it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index b96821f761..bf1266981e 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -103,7 +103,7 @@ describe('MessageItem arms', () => { it('context and unknown nodes render their JSON rows', () => { const ctxView = render( - , + , ) expect(ctxView.getByText(/上下文注入/)).toBeTruthy() const unknownView = render( diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 9ade2abbe3..aa9451b413 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -59,8 +59,7 @@ function snapshotWith( pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, - modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null }, - } as ConversationSnapshot + } } /** Test-owned AppFrame role: declares and renders the resident conversation area. */ @@ -168,6 +167,28 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => { + const parent = 'call-cordis' + const code = 'return { name: "audit", apply(ctx) {} }' + const dispatches = new Map([[parent, [ + subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'), + subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'), + subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'), + ]]]) + const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const view = mountApp(b.slots) + const nest = view.container.querySelector('[data-subcalls]')! + + expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const mounted = nest.querySelector('[data-variant="code"]') + expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) + expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent) + .toContain('Unmount temporary Plugindyn-2') + + fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2ae1d88eca..991aca36e0 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -36,7 +36,7 @@ function makeSource(init?: Partial) { let snap: ConversationSnapshot = { ...snapshotBase(), ...init } const subs = new Set<() => void>() return { - set(next: Partial) { + set: (next: Partial) => { snap = { ...snap, ...next } for (const fn of [...subs]) fn() }, @@ -100,9 +100,9 @@ describe('StatsLine', () => { render() const before = renders // Chunk frames swap partial only; nodes keeps its reference (object-layer contract). - act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } })) - act(() => set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } })) - act(() => set({ running: true })) + act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) }) + act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'ab' }] } }) }) + act(() => { set({ running: true }) }) expect(renders).toBe(before) }) }) @@ -128,7 +128,7 @@ describe('bash sample row', () => { }, current: undefined, phase: 'ready', - } as SessionListState) + }) } const rowProps = (sessionId: SessionId, over?: { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index a221a4028b..13a74548b4 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -31,6 +31,9 @@ describe('tool-call-model', () => { expect(classifyTool('grep')).toBe('search') expect(classifyTool('write')).toBe('write') expect(classifyTool('edit')).toBe('edit') + expect(classifyTool('cordis_inspect')).toBe('read') + expect(classifyTool('cordis_mount')).toBe('code') + expect(classifyTool('cordis_unmount')).toBe('others') expect(classifyTool('todo_write')).toBe('others') }) @@ -67,6 +70,33 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull() expect(toolRowModel('bash', result({ call: null })).body).toBeNull() }) + + it('gives Cordis lifecycle tools action titles over their generic variants', () => { + expect(toolRowModel('cordis_inspect', running({ + name: 'cordis_inspect', + argsRaw: '{"what":"api","name":"tools"}', + }))).toMatchObject({ + variant: 'read', + title: 'Inspect', + summary: 'api', + }) + expect(toolRowModel('cordis_mount', running({ + name: 'cordis_mount', + argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}', + }))).toMatchObject({ + variant: 'code', + title: 'Mount temporary Plugin', + summary: 'return { name: "audit", apply(ctx) {} }', + body: 'return { name: "audit", apply(ctx) {} }', + }) + expect(toolRowModel('cordis_unmount', result({ + call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' }, + }))).toMatchObject({ + variant: 'others', + title: 'Unmount temporary Plugin', + summary: 'dyn-2', + }) + }) }) describe('ToolRow', () => { diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 31afe6b558..87e188bbbd 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -12,7 +12,7 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState, @@ -42,8 +42,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, - modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null }, - } as ConversationSnapshot + } } /** Test-owned AppFrame role: declares and renders the resident conversation area. */ @@ -109,6 +108,10 @@ async function bench(nodes: ToolResultNode[]) { return info }, maybeProvideInfo(id: string | undefined) { + // `this` inside an object-literal method is any under strict lint; the + // fake resolves through its own provideInfo above. + /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, @@ -163,6 +166,25 @@ describe('keyed toolview hole through the real machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) + it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => { + const code = 'return { name: "audit", apply(ctx) {} }' + const b = await bench([ + toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'), + toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })), + toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'), + ]) + const view = mountApp(b.slots) + + expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect') + const mounted = view.container.querySelector('[data-variant="code"]') + expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`) + expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent) + .toContain('Unmount temporary Plugindyn-2') + + fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) + }) + it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => { const b = await bench([toolResult(3, 'c1', 'bash')]) const view = mountApp(b.slots) @@ -253,7 +275,7 @@ describe('registrant load-order seam', () => { children: { 'conversation': { kind: 'single', scope: 'session-maybe' }, 'details': { kind: 'single', scope: 'session' }, - }, + }, }, AppRoot) // Third-party posture, mounted BEFORE ui-conversation: real fiber inject diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 8d50fe47db..20389c9e23 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -7,7 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { - AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, + AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, + SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' @@ -39,7 +40,7 @@ function makeSource(init?: Partial) { let snap: ConversationSnapshot = { ...snapshotBase(), ...init } const subs = new Set<() => void>() return { - set(next: Partial) { + set: (next: Partial) => { snap = { ...snap, ...next } for (const fn of [...subs]) fn() }, @@ -104,8 +105,8 @@ function makeHarness(init?: Partial) { useSession: bindSnapshotSelector(source), useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), - useInput: (() => { throw new Error('unused') }) as never, - inputActions: { setDraft: () => {}, submit: () => {} } as never, + useInput: (() => { throw new Error('unused') }), + inputActions: { setDraft: () => {}, submit: () => {} }, useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, @@ -124,9 +125,9 @@ describe('chat-flow derivation', () => { assistant(5, 'found'), toolResult(6, 'c'), ] const items = deriveChatFlow(nodes) - expect(items.map((i) => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) + expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) const group = items[2]! - expect(group.kind === 'tool-group' && group.results.map((r) => r.callId)).toEqual(['a', 'b']) + expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) @@ -155,7 +156,7 @@ describe('ChatView', () => { fireEvent.scroll(scroller) fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) - act(() => h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] })) + act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800) }) @@ -240,10 +241,10 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = (((_key: string, _owner: object) => { + h.props.renderSlot = ((_key: string, _owner: object) => { rowRenders += 1 return
- }) as unknown as ChatViewSlotProps['renderSlot']) + }) const view = render() expect(view.getByTestId('counting-row')).toBeTruthy() const afterMount = rowRenders @@ -270,7 +271,7 @@ describe('ChatView', () => { fireEvent.click(view.getByText('run a')) expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' }) expect(view.container.querySelector('[data-selected]')).toBeNull() - act(() => h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' })) + act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) expect(view.container.querySelector('[data-selected]')).not.toBeNull() }) @@ -284,10 +285,10 @@ describe('ChatView', () => { it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')] }) const calls: { key: string; entryKey?: string }[] = [] - h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { + h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) return opts?.fallback ?? null - }) as unknown as ChatViewSlotProps['renderSlot']) + }) render() // Keyed dispatch: slot name is the declared hole, entryKey the wire tool // name, and the fallback (GenericToolCard) renders on an empty ledger. @@ -306,10 +307,10 @@ describe('ChatView', () => { // Arm the paging anchor, then deliver an older page (head seq decreases). fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) - act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })) + act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) }) expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000) // A new trailing user bubble (own words) force-scrolls to the bottom. - act(() => h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] })) + act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) }) expect(scroller.scrollTop).toBe(1600) }) @@ -324,7 +325,7 @@ describe('ChatView', () => { const backButton = view.getByLabelText('回到底部') expect(backButton).toBeTruthy() // Streaming growth must NOT drag a scrolled-away reader down. - act(() => h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })) + act(() => { h.set({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) }) expect(scroller.scrollTop).toBe(100) fireEvent.click(backButton) expect(scroller.scrollTop).toBe(1000) @@ -337,7 +338,7 @@ describe('ChatView', () => { const view = render() fireEvent.click(view.getByText('加载更早')) expect(h.loadOlder).toHaveBeenCalledTimes(1) - act(() => h.set({ loadingOlder: true })) + act(() => { h.set({ loadingOlder: true }) }) expect(view.getByText('加载中…')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 18ac9a2891..136901bd6d 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -22,7 +22,7 @@ afterEach(cleanup) describe('tails', () => { it('node-half apply is an intentional no-op', () => { - expect(nodeApply()).toBeUndefined() + expect(() => { nodeApply() }).not.toThrow() }) it('ToolRow stopped state renders the warning dot in the leading slot', () => { @@ -83,7 +83,7 @@ describe('tails', () => { byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - } as SessionListState) + }) const props = (block: RunningToolCall | ToolResultNode) => ({ callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(), sessionId: sid, useSessions: bindSnapshotSelector(list), diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 47f3ae6844..8ee049f899 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -21,8 +21,7 @@ function snapshotBase(): ConversationSnapshot { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, - modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null }, - } as ConversationSnapshot + } } describe('render branch tails', () => { @@ -74,11 +73,11 @@ describe('render branch tails', () => { const view = render( snap, subscribe: () => () => {} }) as unknown as UseSession} + useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(emptyList)} useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} - useInput={(() => { throw new Error('unused') }) as never} - inputActions={{ setDraft: () => {}, submit: () => {} } as never} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} @@ -109,11 +108,11 @@ describe('render branch tails', () => { const view = render( snap, subscribe: () => () => {} }) as unknown as UseSession} + useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(emptyList)} useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} - useInput={(() => { throw new Error('unused') }) as never} - inputActions={{ setDraft: () => {}, submit: () => {} } as never} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 06a8d266a0..c50a35110a 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -79,11 +79,11 @@ function bench(over?: BenchOptions) { useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - })) as InputBarProps['useSessions'], + })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, - })) as InputBarProps['useWorkspaces'], + })), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -212,7 +212,7 @@ describe('running and lock semantics (queue cut 1)', () => { const { textarea, wiring } = bench() fireEvent.change(textarea, { target: { value: 'typed' } }) expect(wiring.state.getSnapshot().draft).toBe('typed') - expect((textarea as HTMLTextAreaElement).value).toBe('typed') + expect((textarea).value).toBe('typed') }) it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { @@ -364,10 +364,10 @@ describe('placeholder chrome and control seats', () => { expect(view.getByTestId('plan-entry')).toBeTruthy() expect(view.getByTestId('model-entry')).toBeTruthy() // The bar hands its chrome disable state to the filling entry. - expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true) + expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true) cleanup() const live = bench({ running: true }) - expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true) + expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) it('disabled locks the Access placeholder and attach control (running does not)', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index caa9b85ad9..284ef6c76a 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -34,11 +34,11 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - })) as InputBarProps['useSessions'], + })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, - })) as InputBarProps['useWorkspaces'], + })), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -88,7 +88,7 @@ describe('matrix row: claimed', () => { expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' }) expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ') expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标') - expect((textarea as HTMLTextAreaElement).readOnly).toBe(false) + expect((textarea).readOnly).toBe(false) // Free editing beyond the token: hint drops, claim holds. fireEvent.change(textarea, { target: { value: '/goal 发布版本' } }) expect(shell.snapshot.phase).toBe('claimed') @@ -104,7 +104,7 @@ describe('matrix row: claimed', () => { expect(sink).not.toHaveBeenCalled() await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) }) // Commit: draft cleared, notice surfaced, back to plain. - await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') }) + await vi.waitFor(() => { expect((textarea).value).toBe('') }) expect(view.getByText('完成')).toBeTruthy() }) @@ -126,7 +126,7 @@ describe('matrix row: submitting', () => { fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') expect(shell.snapshot.claim).toBeDefined() - expect((textarea as HTMLTextAreaElement).readOnly).toBe(true) + expect((textarea).readOnly).toBe(true) expect(view.container.querySelector('[data-input-pending]')).not.toBeNull() // Enter is dead inside the lock (submit dispatch is microtask-deferred). await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) }) @@ -145,7 +145,7 @@ describe('matrix row: submitting', () => { await vi.waitFor(() => { expect(submit).toHaveBeenCalled() }) act(() => { rejectSubmit(new Error('执行失败')) }) await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') }) - expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ') + expect((first.textarea).value).toBe('/goal ') expect(first.view.getByText('执行失败')).toBeTruthy() cleanup() // Drift: typing during flight wins; no restore, plain, notice only. @@ -157,7 +157,7 @@ describe('matrix row: submitting', () => { act(() => { second.shell.setDraft('用户飞行中打的新稿') }) act(() => { rejectSubmit(new Error('晚到失败')) }) await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') }) - expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿') + expect((second.textarea).value).toBe('用户飞行中打的新稿') expect(second.view.getByText('晚到失败')).toBeTruthy() }) }) @@ -165,14 +165,14 @@ describe('matrix row: submitting', () => { describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) - expect((textarea as HTMLTextAreaElement).disabled).toBe(true) + expect((textarea).disabled).toBe(true) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => { const { textarea, sink } = bench({ running: true }) - expect((textarea as HTMLTextAreaElement).disabled).toBe(false) + expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('排队', 'queue') diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index ca71a2f0f8..414f3c15b4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -12,7 +12,6 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -100,7 +99,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { await ctx.plugin(SlashService).await() const slash = ctx.get('slash') as SlashService register?.(slash) - const actx = sessions.scope(sessionId)! as ClientContext + const actx = sessions.scope(sessionId)! const controller = slash.sessionOf(actx) const sink = vi.fn() const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink }) @@ -121,11 +120,11 @@ async function scopedBench(register?: (slash: SlashService) => void) { useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - })) as InputBarProps['useSessions'], + })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, - })) as InputBarProps['useWorkspaces'], + })), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -134,7 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { variant: 'composer', } const view = render() - const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement + const textarea = view.container.querySelector('textarea')! const type = (text: string): void => { fireEvent.change(textarea, { target: { value: text } }) } @@ -145,7 +144,7 @@ async function bench(executeImpl?: (line: string) => Promise) { const execute = vi.fn(executeImpl ?? ((line: string) => Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` }))) const { source, executed } = commandSource(COMMANDS, execute) - const base = await scopedBench((slash) => { slash.registerSource(source as never) }) + const base = await scopedBench((slash) => { slash.registerSource(source) }) return { ...base, execute, executed } } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9b1eac5b93..b777c85ac3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -3,7 +3,7 @@ // hero (blank session) and active phases — same textarea DOM node, machine- // owned draft, and the hero workspace picker (switching = retargetWorkspace). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial = {}): Co } } -function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) { +function mount( + snapshot: ConversationSnapshot, + workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], + retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), +) { const root = sid('root') const sessions = createSnapshotStore({ ids: [root, SID], @@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = const inputActions = wiring.actions const stop = vi.fn() const open = vi.fn() - const retargetWorkspace = vi.fn() const slotCalls: string[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { @@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => { }) it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + ) // Hero chrome present, view ring absent. expect(b.view.getByText("Let's start building")).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() @@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } expect(owner.open).toBe(true) - owner.onPick(wid('second')) + act(() => { owner.onPick(wid('second')) }) expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.getByText('Selected Folder')).toBeTruthy() }) it('textarea DOM identity survives the hero → active flip', () => { @@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByTestId('view-chat')).toBeTruthy() }) + it('rolls the pending workspace label back when switching fails', async () => { + const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + selectWorkspace, + ) + fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) + const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void } + await act(async () => { owner.onPick(wid('second')); await Promise.resolve() }) + expect(selectWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.queryByText('Selected Folder')).toBeNull() + expect(b.view.getByText('one')).toBeTruthy() + }) + it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const chip = b.view.getByRole('button', { name: 'Choose workspace' }) diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 2df7920032..a7c7696222 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -33,7 +33,10 @@ function DetailsColumn(props: { children?: ReactNode }) { return
{props.children}
} -/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */ +/** + * One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. + * `side` keys the hover-reveal CSS to the owning column. + */ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { const [dragging, setDragging] = useState(false) const origin = useRef(0) @@ -86,7 +89,7 @@ export function AppFrame({ actions, renderSlot, }: AppFrameProps) { - const panels = useStore((s) => s) + const panels = useStore(s => s) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 7f86ee7823..4d5f6de30d 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -39,7 +39,7 @@ let fireResize: (() => void) | null = null class ResizeObserverStub { #cb: ResizeObserverCallback constructor(cb: ResizeObserverCallback) { this.#cb = cb } - observe(): void { fireResize = () => { this.#cb([], this as unknown as ResizeObserver) } } + observe(): void { fireResize = () => { this.#cb([], this) } } unobserve(): void {} disconnect(): void { fireResize = null } } @@ -48,7 +48,7 @@ let frameWidth = 1920 /** Test-local selector hook over a framework-neutral store instance. */ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { - return (sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) + return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) } } function mountFrame() { @@ -118,7 +118,7 @@ beforeEach(() => { vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) }) window.innerWidth = frameWidth Element.prototype.getBoundingClientRect = function () { - return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } as DOMRect + return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) } } // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass. const captured = new WeakSet() @@ -143,12 +143,12 @@ describe('AppFrame', () => { const { slotCalls, getByTestId } = mountFrame() expect(getByTestId('center-content')).toBeTruthy() expect(getByTestId('details-content')).toBeTruthy() - const keys = slotCalls.map((c) => c.key) + const keys = slotCalls.map(c => c.key) expect(keys).toContain('conversation') expect(keys).toContain('details') expect(keys).not.toContain('conversation.empty') - expect(slotCalls.find((c) => c.key === 'conversation')!.props).toEqual({}) - expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({}) + expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({}) + expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({}) }) it('keeps the conversation slot mounted while no session is current', () => { @@ -157,7 +157,7 @@ describe('AppFrame', () => { sessionMode.current = false const { slotCalls, getByTestId } = mountFrame() expect(getByTestId('center-content')).toBeTruthy() - expect(slotCalls.map((c) => c.key)).toContain('conversation') + expect(slotCalls.map(c => c.key)).toContain('conversation') }) it('renders both column occupants before baselines settle (no loading gate)', () => { @@ -165,13 +165,13 @@ describe('AppFrame', () => { // pending rendering — both occupants mount from first paint. baselinesReady.current = false const { slotCalls } = mountFrame() - expect(slotCalls.map((c) => c.key)).toContain('conversation') - expect(slotCalls.map((c) => c.key)).toContain('details') + expect(slotCalls.map(c => c.key)).toContain('conversation') + expect(slotCalls.map(c => c.key)).toContain('details') }) it('sidebar slot receives live concession output as owner props', () => { const { slotCalls } = mountFrame() - expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) + expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) }) it('sidebar drag widens through rAF-batched pointer moves', () => { @@ -211,7 +211,7 @@ describe('AppFrame', () => { expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360]) expect(getByTestId('sidebar-content')).toBeTruthy() expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true) - const lastSidebarCall = slotCalls.filter((c) => c.key === 'sidebar').at(-1)! + const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)! expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED }) }) diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 642372868a..d2e39dbf23 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr variant?: ButtonVariant size?: 'md' | 'sm' icon?: ReactNode - className?: string + className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 9abb6a3bb2..6ed7f6c0c2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} > - {items.map(entry => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} + {items.map((entry) => { + if (isSeparator(entry)) { + return
+ } + if (isLabel(entry)) { + return
{entry.text}
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))}
- ) - })} + )} +
+ ) + })}
) diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index e2a49f6579..2c48854055 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -27,7 +27,8 @@ interface AnchorProps { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). - * @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions). + * @param props.disabled - suppress the bubble while true; the anchor renders identically so + * toggling never remounts it (which would cut its CSS transitions). * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4c2083bae1..46bf94c865 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => ( - + ) /** ic_ds_personalization_outline_16 (figma extract) */ export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => ( - + ) /** ic_ds_project_add_outline_16 (figma extract) */ @@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) => - + ) /** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */ @@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => ( - + ) /** folder_close_16 (figma extract) */ export const IconFolderClose16 = ({ size = 16, className }: IconProps) => ( - + ) /** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */ diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 151af94e1c..de6a478af4 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -20,6 +20,9 @@ export interface CodeBlockProps { /** @returns true only when the host accepted the write. */ async function writeClipboard(text: string): Promise { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text) @@ -30,6 +33,9 @@ async function writeClipboard(text: string): Promise { } } // jsdom and older hosts: best-effort execCommand path when present. + // execCommand('copy') is the only clipboard fallback where the async API + // is missing; deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ const exec = typeof document.execCommand === 'function' ? document.execCommand.bind(document) : undefined @@ -48,6 +54,7 @@ async function writeClipboard(text: string): Promise { } finally { el.remove() } + /* eslint-enable @typescript-eslint/no-deprecated */ } export function CodeBlock({ code, lang, className }: CodeBlockProps) { @@ -64,20 +71,20 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) { void writeClipboard(text).then((ok) => { if (!ok) return setCopied(true) - window.setTimeout(() => setCopied(false), 1000) + window.setTimeout(() => { setCopied(false) }, 1000) }) }, [copied, trimmed]) const body = html === undefined ? ( -
{trimmed}
- ) +
{trimmed}
+ ) : ( - // eslint-disable-next-line react/no-danger -- shiki's output is a static - // span tree it generated from `code` (no user HTML passes through), the - // sanctioned innerHTML consumption path per shiki's own docs. -
- ) + // shiki's output is a static span tree it generated from `code` (no user + // HTML passes through), the sanctioned innerHTML consumption path per + // shiki's own docs. +
+ ) return (
diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx index c916c8303d..ecbf594261 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx @@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: { if (!open) return '' let s: string try { + // lib typing hides stringify's undefined arm (undefined/function/symbol payloads). + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition s = JSON.stringify(payload, null, 2) ?? String(payload) } catch { s = String(payload) @@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: { }, [open, payload]) return (
- {open &&
{body}
} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 775978a275..639f53dbb1 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url) /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean): Components { return { - a: ({ href = '', children }) => { - const safeHref = sanitizeUrl(href) - if (safeHref === '') return <>{children} - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) - return ( - - {children} - - ) - }, - img: ({ alt = '' }) => {alt}, - table: ({ children }) => ( -
- {children}
-
- ), + a: ({ href = '', children }) => { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) + }, + img: ({ alt = '' }) => {alt}, + table: ({ children }) => ( +
+ {children}
+
+ ), // Fenced blocks route through the shared CodeBlock (shiki for registered // grammars, identical-geometry plain fallback for unknown/absent // languages); inline code keeps the default path (the :not(pre) @@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components { // plain arm — retokenizing a growing fence on every chunk is quadratic // main-thread work; the finalize swap highlights it once. pre: ({ children }) => { - /* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */ + // The markdown pipeline always hands `pre` its single `code` element; + // the undefined arm guards a react-markdown representation change. + /* v8 ignore next 2 */ const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined const raw = child?.props.children // A fence whose content isn't one plain string (e.g. an empty fence) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index c7a95f49fb..ce599c0258 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number wrapper.getBoundingClientRect = () => ({ top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), - } as DOMRect) + }) } function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 281124b8d5..86cf99e49b 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => { expect(iconNames.length).toBe(55) }) - it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { + it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { const Icon = icons[name]! const { container } = render() const svg = container.querySelector('svg') diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 07df7cebdc..b7f665c78a 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -153,7 +153,7 @@ describe('JsonBlock', () => { it('truncates beyond the size cap with a suffix note', () => { const big = 'x'.repeat(30_000) const { container } = render() - const body = container.querySelector('pre')!.textContent! + const body = container.querySelector('pre')!.textContent expect(body.length).toBeLessThan(30_000) expect(body).toContain('截断') }) diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index 0d2cf52ef2..a3759174ff 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('StateDot', () => { - it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => { + it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => { const { container } = render() const dot = container.firstElementChild as HTMLElement expect(dot.dataset['state']).toBe(state) diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 6c0c1b854c..19306bd552 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -128,6 +128,11 @@ gap: 10px; width: 100%; min-height: 42px; + /* Rows are the scroll content, never the slack absorber: a shrinkable row + collapses to min-height while its wrapped copy keeps the taller + intrinsic height, and centered content then paints outside the row box — + over the title and the next row. Overflow belongs to .options. */ + flex-shrink: 0; padding: 5px 8px; border: 1px solid transparent; border-radius: 12px; @@ -208,6 +213,9 @@ } .custom { + /* Same reason as .option: the custom block is scroll content, and shrinking + it pushes its trigger row (and the open textarea) past the footer. */ + flex-shrink: 0; border: 1px solid transparent; border-radius: 12px; } diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 3571263f61..4380ae8db0 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string { /** Return whether a textarea key event belongs to an active IME composition. */ function isComposing(event: KeyboardEvent): boolean { + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + // eslint-disable-next-line @typescript-eslint/no-deprecated return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 } @@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { }))) const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null) const [error, setError] = useState(null) + // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const question = questions[index]! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 @@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const skipQuestion = (): void => { const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? { - selected: [], custom: '', - customOpen: (question.options?.length ?? 0) === 0, - skipped: true, - } + selected: [], custom: '', + customOpen: (question.options?.length ?? 0) === 0, + skipped: true, + } : item) setDrafts(nextDrafts) setError(null) diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 2bc9289cef..dd130d80e2 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -50,7 +50,7 @@ const QUESTIONS = [ /** Carrier fixture: a real PendingWait over a scripted respond carrier. */ function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve({ accepted: true }))) { const carrier = new PendingWait( - 'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond) + 'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond) return { carrier, respond } } @@ -99,7 +99,7 @@ describe('QuestionComposer', () => { { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, { id: 'signals', selected: ['系统设计', '代码质量'] }, ])) - expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) it('skips individual questions without discarding earlier answers', () => { @@ -173,7 +173,7 @@ describe('QuestionComposer', () => { // Receipt rejection surfaces through the domain face's thrown message. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy() - expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false) + expect(screen.getByRole('button', { name: '跳过本题' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() @@ -199,7 +199,7 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) expect(await screen.findByText('网络中断')).toBeTruthy() - expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false) + expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '提交' })) expect(await screen.findByText('字符串错误')).toBeTruthy() diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 2a041c6cf4..f4395b4fba 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -49,7 +49,7 @@ describe('GeneralSection', () => { mount() expect(screen.getByText('Permission')).toBeTruthy() expect(screen.getByText('Choose default permission mode')).toBeTruthy() - const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + const selector = screen.getByRole('button', { name: /Read only/ }) expect(selector.disabled).toBe(true) }) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 04fa39a03c..0d12135311 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { // Local selection; entries can unmount underneath it, so the render-time // projection falls back to the first row when the id is gone. const [activeId, setActiveId] = useState(undefined) - const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id const titleId = useId() useEffect(() => { @@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {